openvibers/openvibe.tours Commits

OpenVibe.Tours v1

OP OpenVibers committed 13 days ago · 901d4b569e · 66 files, +8879 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..e637b9e
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,75 @@
++# OpenVibe.Tours — copy to /etc/openvibe/tours.env in production; never commit the real file.
++NODE_ENV=production
++PORT=5170
++HOST=127.0.0.1
++BASE_URL=https://openvibe.tours
++
++# PostgreSQL (ADR-035): DATABASE_URL serves, DATABASE_DIRECT_URL migrates with the owner role.
++DATABASE_URL=postgres://openvibe_tours:[email protected]:6432/openvibe_tours
++DATABASE_DIRECT_URL=postgres://openvibe_tours:[email protected]:5432/openvibe_tours
++VALKEY_URL=redis://127.0.0.1:6379
++VALKEY_PREFIX=ov:tours:
++
++# OpenVibe.Network: OAuth client tours + the session JWKS.
++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=tours
++OV_OAUTH_CLIENT_SECRET=
++OV_OAUTH_REDIRECT_URI=https://openvibe.tours/auth/callback
++OV_SESSION_AUDIENCE=openvibe.network
++TOURS_AUDIENCE=openvibe.tours
++COOKIE_SECURE=true
++
++# Account export/deletion delivery from OpenVibe.Events (32+ chars, comma-separated for rotation).
++TOURS_EVENTS_SECRET=
++TOURS_EVENTS_URL=https://openvibe.events
++TOURS_EVENTS_ENDPOINT=
++TOURS_EVENTS_SCOPE=events.subscription.manage
++
++# The public APIs and open data the jobs read (all keyless).
++OV_LIVE_URL=https://openvibe.live
++OV_LIVE_PUBLIC_URL=https://openvibe.live
++OV_VIDEO_PUBLIC_URL=https://openvibe.video
++OV_RENT_URL=https://openvibe.rent
++OV_RENT_PUBLIC_URL=https://openvibe.rent
++OV_WIKIVOYAGE_URL=https://en.wikivoyage.org
++OV_WIKIDATA_URL=https://www.wikidata.org
++OV_WIKIPEDIA_URL=https://en.wikipedia.org
++OV_COMMONS_URL=https://commons.wikimedia.org
++OV_OVERPASS_URL=https://overpass-api.de/api/interpreter
++
++# Identify every OpenStreetMap request; pace each host; bound each city query by radius.
++TOURS_USER_AGENT=OpenVibeTours/0.1 (+https://openvibe.tours; [email protected])
++TOURS_OSM_EMAIL=
++TOURS_MIN_INTERVAL_MS=1100
++TOURS_OSM_RADIUS_M=3500
++TOURS_OSM_MAX_PER_CITY=80
++TOURS_CITY_LIMIT=100
++TOURS_WIKIVOYAGE_DISTRICTS=2
++TOURS_UPSTREAM_TIMEOUT_MS=60000
++TOURS_OSM_CITIES_PER_RUN=10
++
++# Source jobs: off under TOURS_DRILL; each has its own refresh interval.
++TOURS_JOBS=on
++TOURS_JOBS_INTERVAL_MS=21600000
++TOURS_CITIES_INTERVAL_MS=86400000
++TOURS_PLACES_INTERVAL_MS=604800000
++TOURS_ROUTES_INTERVAL_MS=604800000
++TOURS_LIVE_INTERVAL_MS=120000
++TOURS_STAYS_INTERVAL_MS=21600000
++
++# Flights: no key in v1. The form links the official airline-neutral search. A key alone leaves the seam off until
++# an adapter exists; never put a real key in this file in git.
++TOURS_FLIGHTS_SEARCH_URL=https://www.google.com/travel/flights
++TOURS_DUFFEL_API_KEY=
++TOURS_KIWI_API_KEY=
++
++# Limits.
++TOURS_RATE_LIMIT_PER_MIN=300
++TOURS_API_RATE_LIMIT_PER_MIN=240
++TOURS_LIMITS_MINUTE=120
++TOURS_LIMITS_HOUR=3000
++
++# Development only: embedded PGlite directory when DATABASE_URL is unset.
++TOURS_PGLITE_DIR=
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..d2a8a4e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++.npm-cache/
++*.log
++*.pem
++*.key
++!server/data/
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..60b8552
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,13 @@
++# Changelog
++
++## 0.1.0 — 2026-10-31
++
++- The top 100 cities from Wikidata and Wikivoyage, places from OpenStreetMap `tourism=*`, and a place of the day.
++- City and place pages with every source and fetch time; TouristDestination, TouristAttraction and TouristTrip JSON-LD.
++- Self-guided routes generated from Wikivoyage See lists, marked as generated, cited stop by stop, on a MapLibre + OSM map.
++- IRL tours now, recent IRL VODs and clips from OpenVibe.Live's public list; stays from OpenVibe.Rent's public listings.
++- A flight form that links the official airline-neutral search with the city pair, no affiliate; Duffel/Kiwi off without an adapter.
++- Trips for a signed-in person: city, dates, saved places, one route, stays; shareable read-only; owner-only edit and delete.
++- `GET /api/v1/cities`, `/cities/:slug`, `/places/:id`, `/routes/:id`, `/live`, trips CRUD, and an OpenAPI 3.1 document.
++- Source jobs with receipts (`tours_job_runs`), off under `TOURS_DRILL`; Network sign-in with PKCE; account deletion.
++- Server-rendered home with the room kit; anchor accent `#F98942` dark / `#AA3900` light (AA in both themes).
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
++ Everyone is permitted to copy and distribute verbatim copies
++ of this license document, but changing it is not allowed.
++
++ Preamble
++
++ The GNU Affero General Public License is a free, copyleft license for
++software and other kinds of works, specifically designed to ensure
++cooperation with the community in the case of network server software.
++
++ The licenses for most software and other practical works are designed
++to take away your freedom to share and change the works. By contrast,
++our General Public Licenses are intended to guarantee your freedom to
++share and change all versions of a program--to make sure it remains free
++software for all its users.
++
++ When we speak of free software, we are referring to freedom, not
++price. Our General Public Licenses are designed to make sure that you
++have the freedom to distribute copies of free software (and charge for
++them if you wish), that you receive source code or can get it if you
++want it, that you can change the software or use pieces of it in new
++free programs, and that you know you can do these things.
++
++ Developers that use our General Public Licenses protect your rights
++with two steps: (1) assert copyright on the software, and (2) offer
++you this License which gives you legal permission to copy, distribute
++and/or modify the software.
++
++ A secondary benefit of defending all users' freedom is that
++improvements made in alternate versions of the program, if they
++receive widespread use, become available for other developers to
++incorporate. Many developers of free software are heartened and
++encouraged by the resulting cooperation. However, in the case of
++software used on network servers, this result may fail to come about.
++The GNU General Public License permits making a modified version and
++letting the public access it on a server without ever releasing its
++source code to the public.
++
++ The GNU Affero General Public License is designed specifically to
++ensure that, in such cases, the modified source code becomes available
++to the community. It requires the operator of a network server to
++provide the source code of the modified version running there to the
++users of that server. Therefore, public use of a modified version, on
++a publicly accessible server, gives the public access to the source
++code of the modified version.
++
++ An older license, called the Affero General Public License and
++published by Affero, was designed to accomplish similar goals. This is
++a different license, not a version of the Affero GPL, but Affero has
++released a new version of the Affero GPL which permits relicensing under
++this license.
++
++ The precise terms and conditions for copying, distribution and
++modification follow.
++
++ TERMS AND CONDITIONS
++
++ 0. Definitions.
++
++ "This License" refers to version 3 of the GNU Affero General Public License.
++
++ "Copyright" also means copyright-like laws that apply to other kinds of
++works, such as semiconductor masks.
++
++ "The Program" refers to any copyrightable work licensed under this
++License. Each licensee is addressed as "you". "Licensees" and
++"recipients" may be individuals or organizations.
++
++ To "modify" a work means to copy from or adapt all or part of the work
++in a fashion requiring copyright permission, other than the making of an
++exact copy. The resulting work is called a "modified version" of the
++earlier work or a work "based on" the earlier work.
++
++ A "covered work" means either the unmodified Program or a work based
++on the Program.
++
++ To "propagate" a work means to do anything with it that, without
++permission, would make you directly or secondarily liable for
++infringement under applicable copyright law, except executing it on a
++computer or modifying a private copy. Propagation includes copying,
++distribution (with or without modification), making available to the
++public, and in some countries other activities as well.
++
++ To "convey" a work means any kind of propagation that enables other
++parties to make or receive copies. Mere interaction with a user through
++a computer network, with no transfer of a copy, is not conveying.
++
++ An interactive user interface displays "Appropriate Legal Notices"
++to the extent that it includes a convenient and prominently visible
++feature that (1) displays an appropriate copyright notice, and (2)
++tells the user that there is no warranty for the work (except to the
++extent that warranties are provided), that licensees may convey the
++work under this License, and how to view a copy of this License. If
++the interface presents a list of user commands or options, such as a
++menu, a prominent item in the list meets this criterion.
++
++ 1. Source Code.
++
++ The "source code" for a work means the preferred form of the work
++for making modifications to it. "Object code" means any non-source
++form of a work.
++
++ A "Standard Interface" means an interface that either is an official
++standard defined by a recognized standards body, or, in the case of
++interfaces specified for a particular programming language, one that
++is widely used among developers working in that language.
++
++ The "System Libraries" of an executable work include anything, other
++than the work as a whole, that (a) is included in the normal form of
++packaging a Major Component, but which is not part of that Major
++Component, and (b) serves only to enable use of the work with that
++Major Component, or to implement a Standard Interface for which an
++implementation is available to the public in source code form. A
++"Major Component", in this context, means a major essential component
++(kernel, window system, and so on) of the specific operating system
++(if any) on which the executable work runs, or a compiler used to
++produce the work, or an object code interpreter used to run it.
++
++ The "Corresponding Source" for a work in object code form means all
++the source code needed to generate, install, and (for an executable
++work) run the object code and to modify the work, including scripts to
++control those activities. However, it does not include the work's
++System Libraries, or general-purpose tools or generally available free
++programs which are used unmodified in performing those activities but
++which are not part of the work. For example, Corresponding Source
++includes interface definition files associated with source files for
++the work, and the source code for shared libraries and dynamically
++linked subprograms that the work is specifically designed to require,
++such as by intimate data communication or control flow between those
++subprograms and other parts of the work.
++
++ The Corresponding Source need not include anything that users
++can regenerate automatically from other parts of the Corresponding
++Source.
++
++ The Corresponding Source for a work in source code form is that
++same work.
++
++ 2. Basic Permissions.
++
++ All rights granted under this License are granted for the term of
++copyright on the Program, and are irrevocable provided the stated
++conditions are met. This License explicitly affirms your unlimited
++permission to run the unmodified Program. The output from running a
++covered work is covered by this License only if the output, given its
++content, constitutes a covered work. This License acknowledges your
++rights of fair use or other equivalent, as provided by copyright law.
++
++ You may make, run and propagate covered works that you do not
++convey, without conditions so long as your license otherwise remains
++in force. You may convey covered works to others for the sole purpose
++of having them make modifications exclusively for you, or provide you
++with facilities for running those works, provided that you comply with
++the terms of this License in conveying all material for which you do
++not control copyright. Those thus making or running the covered works
++for you must do so exclusively on your behalf, under your direction
++and control, on terms that prohibit them from making any copies of
++your copyrighted material outside their relationship with you.
++
++ Conveying under any other circumstances is permitted solely under
++the conditions stated below. Sublicensing is not allowed; section 10
++makes it unnecessary.
++
++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
++
++ No covered work shall be deemed part of an effective technological
++measure under any applicable law fulfilling obligations under article
++11 of the WIPO copyright treaty adopted on 20 December 1996, or
++similar laws prohibiting or restricting circumvention of such
++measures.
++
++ When you convey a covered work, you waive any legal power to forbid
++circumvention of technological measures to the extent such circumvention
++is effected by exercising rights under this License with respect to
++the covered work, and you disclaim any intention to limit operation or
++modification of the work as a means of enforcing, against the work's
++users, your or third parties' legal rights to forbid circumvention of
++technological measures.
++
++ 4. Conveying Verbatim Copies.
++
++ You may convey verbatim copies of the Program's source code as you
++receive it, in any medium, provided that you conspicuously and
++appropriately publish on each copy an appropriate copyright notice;
++keep intact all notices stating that this License and any
++non-permissive terms added in accord with section 7 apply to the code;
++keep intact all notices of the absence of any warranty; and give all
++recipients a copy of this License along with the Program.
++
++ You may charge any price or no price for each copy that you convey,
++and you may offer support or warranty protection for a fee.
++
++ 5. Conveying Modified Source Versions.
++
++ You may convey a work based on the Program, or the modifications to
++produce it from the Program, in the form of source code under the
++terms of section 4, provided that you also meet all of these conditions:
++
++ a) The work must carry prominent notices stating that you modified
++ it, and giving a relevant date.
++
++ b) The work must carry prominent notices stating that it is
++ released under this License and any conditions added under section
++ 7. This requirement modifies the requirement in section 4 to
++ "keep intact all notices".
++
++ c) You must license the entire work, as a whole, under this
++ License to anyone who comes into possession of a copy. This
++ License will therefore apply, along with any applicable section 7
++ additional terms, to the whole of the work, and all its parts,
++ regardless of how they are packaged. This License gives no
++ permission to license the work in any other way, but it does not
++ invalidate such permission if you have separately received it.
++
++ d) If the work has interactive user interfaces, each must display
++ Appropriate Legal Notices; however, if the Program has interactive
++ interfaces that do not display Appropriate Legal Notices, your
++ work need not make them do so.
++
++ A compilation of a covered work with other separate and independent
++works, which are not by their nature extensions of the covered work,
++and which are not combined with it such as to form a larger program,
++in or on a volume of a storage or distribution medium, is called an
++"aggregate" if the compilation and its resulting copyright are not
++used to limit the access or legal rights of the compilation's users
++beyond what the individual works permit. Inclusion of a covered work
++in an aggregate does not cause this License to apply to the other
++parts of the aggregate.
++
++ 6. Conveying Non-Source Forms.
++
++ You may convey a covered work in object code form under the terms
++of sections 4 and 5, provided that you also convey the
++machine-readable Corresponding Source under the terms of this License,
++in one of these ways:
++
++ a) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by the
++ Corresponding Source fixed on a durable physical medium
++ customarily used for software interchange.
++
++ b) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by a
++ written offer, valid for at least three years and valid for as
++ long as you offer spare parts or customer support for that product
++ model, to give anyone who possesses the object code either (1) a
++ copy of the Corresponding Source for all the software in the
++ product that is covered by this License, on a durable physical
++ medium customarily used for software interchange, for a price no
++ more than your reasonable cost of physically performing this
++ conveying of source, or (2) access to copy the
++ Corresponding Source from a network server at no charge.
++
++ c) Convey individual copies of the object code with a copy of the
++ written offer to provide the Corresponding Source. This
++ alternative is allowed only occasionally and noncommercially, and
++ only if you received the object code with such an offer, in accord
++ with subsection 6b.
++
++ d) Convey the object code by offering access from a designated
++ place (gratis or for a charge), and offer equivalent access to the
++ Corresponding Source in the same way through the same place at no
++ further charge. You need not require recipients to copy the
++ Corresponding Source along with the object code. If the place to
++ copy the object code is a network server, the Corresponding Source
++ may be on a different server (operated by you or a third party)
++ that supports equivalent copying facilities, provided you maintain
++ clear directions next to the object code saying where to find the
++ Corresponding Source. Regardless of what server hosts the
++ Corresponding Source, you remain obligated to ensure that it is
++ available for as long as needed to satisfy these requirements.
++
++ e) Convey the object code using peer-to-peer transmission, provided
++ you inform other peers where the object code and Corresponding
++ Source of the work are being offered to the general public at no
++ charge under subsection 6d.
++
++ A separable portion of the object code, whose source code is excluded
++from the Corresponding Source as a System Library, need not be
++included in conveying the object code work.
++
++ A "User Product" is either (1) a "consumer product", which means any
++tangible personal property which is normally used for personal, family,
++or household purposes, or (2) anything designed or sold for incorporation
++into a dwelling. In determining whether a product is a consumer product,
++doubtful cases shall be resolved in favor of coverage. For a particular
++product received by a particular user, "normally used" refers to a
++typical or common use of that class of product, regardless of the status
++of the particular user or of the way in which the particular user
++actually uses, or expects or is expected to use, the product. A product
++is a consumer product regardless of whether the product has substantial
++commercial, industrial or non-consumer uses, unless such uses represent
++the only significant mode of use of the product.
++
++ "Installation Information" for a User Product means any methods,
++procedures, authorization keys, or other information required to install
++and execute modified versions of a covered work in that User Product from
++a modified version of its Corresponding Source. The information must
++suffice to ensure that the continued functioning of the modified object
++code is in no case prevented or interfered with solely because
++modification has been made.
++
++ If you convey an object code work under this section in, or with, or
++specifically for use in, a User Product, and the conveying occurs as
++part of a transaction in which the right of possession and use of the
++User Product is transferred to the recipient in perpetuity or for a
++fixed term (regardless of how the transaction is characterized), the
++Corresponding Source conveyed under this section must be accompanied
++by the Installation Information. But this requirement does not apply
++if neither you nor any third party retains the ability to install
++modified object code on the User Product (for example, the work has
++been installed in ROM).
++
++ The requirement to provide Installation Information does not include a
++requirement to continue to provide support service, warranty, or updates
++for a work that has been modified or installed by the recipient, or for
++the User Product in which it has been modified or installed. Access to a
++network may be denied when the modification itself materially and
++adversely affects the operation of the network or violates the rules and
++protocols for communication across the network.
++
++ Corresponding Source conveyed, and Installation Information provided,
++in accord with this section must be in a format that is publicly
++documented (and with an implementation available to the public in
++source code form), and must require no special password or key for
++unpacking, reading or copying.
++
++ 7. Additional Terms.
++
++ "Additional permissions" are terms that supplement the terms of this
++License by making exceptions from one or more of its conditions.
++Additional permissions that are applicable to the entire Program shall
++be treated as though they were included in this License, to the extent
++that they are valid under applicable law. If additional permissions
++apply only to part of the Program, that part may be used separately
++under those permissions, but the entire Program remains governed by
++this License without regard to the additional permissions.
++
++ When you convey a copy of a covered work, you may at your option
++remove any additional permissions from that copy, or from any part of
++it. (Additional permissions may be written to require their own
++removal in certain cases when you modify the work.) You may place
++additional permissions on material, added by you to a covered work,
++for which you have or can give appropriate copyright permission.
++
++ Notwithstanding any other provision of this License, for material you
++add to a covered work, you may (if authorized by the copyright holders of
++that material) supplement the terms of this License with terms:
++
++ a) Disclaiming warranty or limiting liability differently from the
++ terms of sections 15 and 16 of this License; or
++
++ b) Requiring preservation of specified reasonable legal notices or
++ author attributions in that material or in the Appropriate Legal
++ Notices displayed by works containing it; or
++
++ c) Prohibiting misrepresentation of the origin of that material, or
++ requiring that modified versions of such material be marked in
++ reasonable ways as different from the original version; or
++
++ d) Limiting the use for publicity purposes of names of licensors or
++ authors of the material; or
++
++ e) Declining to grant rights under trademark law for use of some
++ trade names, trademarks, or service marks; or
++
++ f) Requiring indemnification of licensors and authors of that
++ material by anyone who conveys the material (or modified versions of
++ it) with contractual assumptions of liability to the recipient, for
++ any liability that these contractual assumptions directly impose on
++ those licensors and authors.
++
++ All other non-permissive additional terms are considered "further
++restrictions" within the meaning of section 10. If the Program as you
++received it, or any part of it, contains a notice stating that it is
++governed by this License along with a term that is a further
++restriction, you may remove that term. If a license document contains
++a further restriction but permits relicensing or conveying under this
++License, you may add to a covered work material governed by the terms
++of that license document, provided that the further restriction does
++not survive such relicensing or conveying.
++
++ If you add terms to a covered work in accord with this section, you
++must place, in the relevant source files, a statement of the
++additional terms that apply to those files, or a notice indicating
++where to find the applicable terms.
++
++ Additional terms, permissive or non-permissive, may be stated in the
++form of a separately written license, or stated as exceptions;
++the above requirements apply either way.
++
++ 8. Termination.
++
++ You may not propagate or modify a covered work except as expressly
++provided under this License. Any attempt otherwise to propagate or
++modify it is void, and will automatically terminate your rights under
++this License (including any patent licenses granted under the third
++paragraph of section 11).
++
++ However, if you cease all violation of this License, then your
++license from a particular copyright holder is reinstated (a)
++provisionally, unless and until the copyright holder explicitly and
++finally terminates your license, and (b) permanently, if the copyright
++holder fails to notify you of the violation by some reasonable means
++prior to 60 days after the cessation.
++
++ Moreover, your license from a particular copyright holder is
++reinstated permanently if the copyright holder notifies you of the
++violation by some reasonable means, this is the first time you have
++received notice of violation of this License (for any work) from that
++copyright holder, and you cure the violation prior to 30 days after
++your receipt of the notice.
++
++ Termination of your rights under this section does not terminate the
++licenses of parties who have received copies or rights from you under
++this License. If your rights have been terminated and not permanently
++reinstated, you do not qualify to receive new licenses for the same
++material under section 10.
++
++ 9. Acceptance Not Required for Having Copies.
++
++ You are not required to accept this License in order to receive or
++run a copy of the Program. Ancillary propagation of a covered work
++occurring solely as a consequence of using peer-to-peer transmission
++to receive a copy likewise does not require acceptance. However,
++nothing other than this License grants you permission to propagate or
++modify any covered work. These actions infringe copyright if you do
++not accept this License. Therefore, by modifying or propagating a
++covered work, you indicate your acceptance of this License to do so.
++
++ 10. Automatic Licensing of Downstream Recipients.
++
++ Each time you convey a covered work, the recipient automatically
++receives a license from the original licensors, to run, modify and
++propagate that work, subject to this License. You are not responsible
++for enforcing compliance by third parties with this License.
++
++ An "entity transaction" is a transaction transferring control of an
++organization, or substantially all assets of one, or subdividing an
++organization, or merging organizations. If propagation of a covered
++work results from an entity transaction, each party to that
++transaction who receives a copy of the work also receives whatever
++licenses to the work the party's predecessor in interest had or could
++give under the previous paragraph, plus a right to possession of the
++Corresponding Source of the work from the predecessor in interest, if
++the predecessor has it or can get it with reasonable efforts.
++
++ You may not impose any further restrictions on the exercise of the
++rights granted or affirmed under this License. For example, you may
++not impose a license fee, royalty, or other charge for exercise of
++rights granted under this License, and you may not initiate litigation
++(including a cross-claim or counterclaim in a lawsuit) alleging that
++any patent claim is infringed by making, using, selling, offering for
++sale, or importing the Program or any portion of it.
++
++ 11. Patents.
++
++ A "contributor" is a copyright holder who authorizes use under this
++License of the Program or a work on which the Program is based. The
++work thus licensed is called the contributor's "contributor version".
++
++ A contributor's "essential patent claims" are all patent claims
++owned or controlled by the contributor, whether already acquired or
++hereafter acquired, that would be infringed by some manner, permitted
++by this License, of making, using, or selling its contributor version,
++but do not include claims that would be infringed only as a
++consequence of further modification of the contributor version. For
++purposes of this definition, "control" includes the right to grant
++patent sublicenses in a manner consistent with the requirements of
++this License.
++
++ Each contributor grants you a non-exclusive, worldwide, royalty-free
++patent license under the contributor's essential patent claims, to
++make, use, sell, offer for sale, import and otherwise run, modify and
++propagate the contents of its contributor version.
++
++ In the following three paragraphs, a "patent license" is any express
++agreement or commitment, however denominated, not to enforce a patent
++(such as an express permission to practice a patent or covenant not to
++sue for patent infringement). To "grant" such a patent license to a
++party means to make such an agreement or commitment not to enforce a
++patent against the party.
++
++ If you convey a covered work, knowingly relying on a patent license,
++and the Corresponding Source of the work is not available for anyone
++to copy, free of charge and under the terms of this License, through a
++publicly available network server or other readily accessible means,
++then you must either (1) cause the Corresponding Source to be so
++available, or (2) arrange to deprive yourself of the benefit of the
++patent license for this particular work, or (3) arrange, in a manner
++consistent with the requirements of this License, to extend the patent
++license to downstream recipients. "Knowingly relying" means you have
++actual knowledge that, but for the patent license, your conveying the
++covered work in a country, or your recipient's use of the covered work
++in a country, would infringe one or more identifiable patents in that
++country that you have reason to believe are valid.
++
++ If, pursuant to or in connection with a single transaction or
++arrangement, you convey, or propagate by procuring conveyance of, a
++covered work, and grant a patent license to some of the parties
++receiving the covered work authorizing them to use, propagate, modify
++or convey a specific copy of the covered work, then the patent license
++you grant is automatically extended to all recipients of the covered
++work and works based on it.
++
++ A patent license is "discriminatory" if it does not include within
++the scope of its coverage, prohibits the exercise of, or is
++conditioned on the non-exercise of one or more of the rights that are
++specifically granted under this License. You may not convey a covered
++work if you are a party to an arrangement with a third party that is
++in the business of distributing software, under which you make payment
++to the third party based on the extent of your activity of conveying
++the work, and under which the third party grants, to any of the
++parties who would receive the covered work from you, a discriminatory
++patent license (a) in connection with copies of the covered work
++conveyed by you (or copies made from those copies), or (b) primarily
++for and in connection with specific products or compilations that
++contain the covered work, unless you entered into that arrangement,
++or that patent license was granted, prior to 28 March 2007.
++
++ Nothing in this License shall be construed as excluding or limiting
++any implied license or other defenses to infringement that may
++otherwise be available to you under applicable patent law.
++
++ 12. No Surrender of Others' Freedom.
++
++ If conditions are imposed on you (whether by court order, agreement or
++otherwise) that contradict the conditions of this License, they do not
++excuse you from the conditions of this License. If you cannot convey a
++covered work so as to satisfy simultaneously your obligations under this
++License and any other pertinent obligations, then as a consequence you may
++not convey it at all. For example, if you agree to terms that obligate you
++to collect a royalty for further conveying from those to whom you convey
++the Program, the only way you could satisfy both those terms and this
++License would be to refrain entirely from conveying the Program.
++
++ 13. Remote Network Interaction; Use with the GNU General Public License.
++
++ Notwithstanding any other provision of this License, if you modify the
++Program, your modified version must prominently offer all users
++interacting with it remotely through a computer network (if your version
++supports such interaction) an opportunity to receive the Corresponding
++Source of your version by providing access to the Corresponding Source
++from a network server at no charge, through some standard or customary
++means of facilitating copying of software. This Corresponding Source
++shall include the Corresponding Source for any work covered by version 3
++of the GNU General Public License that is incorporated pursuant to the
++following paragraph.
++
++ Notwithstanding any other provision of this License, you have
++permission to link or combine any covered work with a work licensed
++under version 3 of the GNU General Public License into a single
++combined work, and to convey the resulting work. The terms of this
++License will continue to apply to the part which is the covered work,
++but the work with which it is combined will remain governed by version
++3 of the GNU General Public License.
++
++ 14. Revised Versions of this License.
++
++ The Free Software Foundation may publish revised and/or new versions of
++the GNU Affero General Public License from time to time. Such new versions
++will be similar in spirit to the present version, but may differ in detail to
++address new problems or concerns.
++
++ Each version is given a distinguishing version number. If the
++Program specifies that a certain numbered version of the GNU Affero General
++Public License "or any later version" applies to it, you have the
++option of following the terms and conditions either of that numbered
++version or of any later version published by the Free Software
++Foundation. If the Program does not specify a version number of the
++GNU Affero General Public License, you may choose any version ever published
++by the Free Software Foundation.
++
++ If the Program specifies that a proxy can decide which future
++versions of the GNU Affero General Public License can be used, that proxy's
++public statement of acceptance of a version permanently authorizes you
++to choose that version for the Program.
++
++ Later license versions may give you additional or different
++permissions. However, no additional obligations are imposed on any
++author or copyright holder as a result of your choosing to follow a
++later version.
++
++ 15. Disclaimer of Warranty.
++
++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
++
++ 16. Limitation of Liability.
++
++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
++SUCH DAMAGES.
++
++ 17. Interpretation of Sections 15 and 16.
++
++ If the disclaimer of warranty and limitation of liability provided
++above cannot be given local legal effect according to their terms,
++reviewing courts shall apply local law that most closely approximates
++an absolute waiver of all civil liability in connection with the
++Program, unless a warranty or assumption of liability accompanies a
++copy of the Program in return for a fee.
++
++ END OF TERMS AND CONDITIONS
++
++ How to Apply These Terms to Your New Programs
++
++ If you develop a new program, and you want it to be of the greatest
++possible use to the public, the best way to achieve this is to make it
++free software which everyone can redistribute and change under these terms.
++
++ To do so, attach the following notices to the program. It is safest
++to attach them to the start of each source file to most effectively
++state the exclusion of warranty; and each file should have at least
++the "copyright" line and a pointer to where the full notice is found.
++
++ <one line to give the program's name and a brief idea of what it does.>
++ Copyright (C) <year> <name of author>
++
++ This program is free software: you can redistribute it and/or modify
++ it under the terms of the GNU Affero General Public License as published
++ by the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ This program is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU Affero General Public License for more details.
++
++ You should have received a copy of the GNU Affero General Public License
++ along with this program. If not, see <https://www.gnu.org/licenses/>.
++
++Also add information on how to contact you by electronic and paper mail.
++
++ If your software can interact with users remotely through a computer
++network, you should also make sure that it provides a way for users to
++get its source. For example, if your program is a web application, its
++interface could display a "Source" link that leads users to an archive
++of the code. There are many ways you could offer source, and different
++solutions will be better for different programs; see section 13 for the
++specific requirements.
++
++ You should also get your employer (if you work as a programmer) or school,
++if any, to sign a "copyright disclaimer" for the program, if necessary.
++For more information on this, and how to apply and follow the GNU AGPL, see
++<https://www.gnu.org/licenses/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..09d294f
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,175 @@
++# OpenVibe.Tours
++
++> Go anywhere: fly, stay, walk, watch.
++
++**Status:** v1 written, seeded and tested (see [STATUS.json](STATUS.json)).
++**Domain:** `openvibe.tours` · **Port:** 5170 · **Service id:** `tours` · **Env prefix:** `TOURS`
++**License:** AGPL-3.0-only (the same as every OpenVibe service).
++
++OpenVibe.Tours is the travel room. V1 has six things, all from open data or the network's public APIs:
++
++- **Places.** The top 100 cities from Wikidata and Wikivoyage, then `tourism=*` places from OpenStreetMap through
++ Overpass, with the city's Wikivoyage See listings as a cited fallback when Overpass is queueing. A page per city (`/city/<slug>`) and per place (`/place/<id>`) with the source and the fetch time, plus
++ a **place of the day** chosen deterministically from the same data.
++- **Live tours.** IRL streams from OpenVibe.Live's public stream list, and recent IRL VODs and clips, with a city
++ when the public title or tags name one. Tours reads the public list; it never follows, chats or writes.
++- **Stays.** Active rentals from OpenVibe.Rent's public listings for the city. Tours never takes a payment.
++- **Self-guided routes.** Ordered stops on a map (MapLibre + OpenStreetMap tiles, with a complete SVG and list
++ without JavaScript), generated from Wikivoyage's See lists, marked as generated, every stop cited. TTS audio is
++ off: OpenVibe.AI has no speech-synthesis route today, so every stop says so instead of shipping a dead player.
++- **Flights.** No flight API key in v1. The form opens the official airline-neutral search with the city pair and
++ dates filled in. **No affiliate.** The Duffel/Kiwi seam is off, and a key alone does not turn it on.
++- **Trips.** A signed-in person's trip: a city, dates, saved places, one route, stays, and a read-only share link.
++ Group voting is v2.
++
++## Owns
++
++| Table | Holds | Migration |
++|---|---|---|
++| `tours_cities` | `cty_` + stable id, slug, name, country/ISO code, coordinates, population, Wikidata Q id, Wikivoyage title, summary, image + Commons licence, source URL, fetched time | `migrations/0001_initial.sql` |
++| `tours_city_sources` | the Wikivoyage pages (city + district) a city's summary, See listings and routes come from | `0001_initial.sql` |
++| `tours_places` | `plc_` + stable id, city, OSM type/id, name, `tourism=*` kind, coordinates, address, hours, wheelchair, fee, Wikidata/Wikipedia, image, source URL, fetched time | `0001_initial.sql` |
++| `tours_routes` | `rte_` + stable id, city, slug, title, summary, distance, duration, stop count, generated flag, Wikivoyage source, fetched time | `0001_initial.sql` |
++| `tours_route_stops` | ordered stop, optional place, cited note, coordinates, Wikivoyage URL, audio status/url | `0001_initial.sql` |
++| `tours_live` | `liv_` + stable id, `stream\|vod\|clip`, Live/Media id, username, title, category, viewers, city when named, public URL, times, fetched time | `0001_initial.sql` |
++| `tours_stays` | `sty_` + stable id, Rent `rnt_` id, city, title, kind, price/currency/period, neighbourhood, public URL, fetched time | `0001_initial.sql` |
++| `tours_trips` | `trp_` + ULID, share slug, owner subject, title, city, dates, notes, `private\|shared`, revision | `0001_initial.sql` |
++| `tours_trip_places`, `tours_trip_routes`, `tours_trip_stays` | what the person saved on the trip (cascade with it) | `0001_initial.sql` |
++| `tours_job_runs` | each source job's start/finish, status, counts and error | `0001_initial.sql` |
++| `account_data_events`, `telemetry_samples` | the SDK's account export/deletion receipts and request telemetry (pruned after 14 days) | `0001_initial.sql` |
++
++## Does not own
++
++- **Live, Rent and AI** — OpenVibe.Live, OpenVibe.Rent and OpenVibe.AI. Tours reads their public APIs and never
++ writes to them. It never uses a service token.
++- **Identity and roles** — OpenVibe.Network. Tours is OAuth client `tours` (PKCE S256) and verifies session tokens
++ against the Network JWKS; a person's canonical subject is `usr_…`.
++- **OpenStreetMap, Wikivoyage, Wikidata, Commons** — external open data, cached in Tours' own tables with the source
++ URL and the time fetched; never scraped past their terms.
++- **Events, host and database** — OpenVibe.Events, OpenVibe.Host.
++
++## Sources, licences and fetch times
++
++| Source | Used for | Licence / terms | Kept |
++|---|---|---|---|
++| OpenStreetMap via Overpass | places (`tourism=*`) | ODbL 1.0; contact User-Agent, per-host pacing, one bounded `around:` query, no bulk download | OSM object URL, fetched time |
++| Wikivoyage | city summary, See listings, route stops | CC BY-SA 4.0; the page URL on every city and stop | page URL, fetched time |
++| Wikidata | coordinates, population, country, English Wikivoyage sitelink | CC0 | Q id, fetched time |
++| Wikimedia Commons | city image + licence/credit | each image's own licence (usually CC BY-SA) | credit, licence, licence URL |
++| OpenVibe.Live | live IRL streams, VODs, clips | public API; read-only | item URL, fetched time |
++| OpenVibe.Rent | stays | public listings; read-only | listing URL, fetched time |
++
++OpenVibe.AI is **not** used: its public API has chat, generate, summarize, classify, extract, embed, image and
++research, but no speech synthesis. The route schema has `audio_status`/`audio_url` ready for the day it does.
++
++## Jobs
++
++Each source has its own job in `server/jobs/`, and each writes a receipt in `tours_job_runs` (status, counts, error).
++They are off under `TOURS_DRILL`; production starts them after listen and refreshes on their own interval.
++
++| Job | Fills | Default interval | Env |
++|---|---|---|---|
++| `cities` | 100 cities, their Wikidata/Commons image and Wikivoyage page + See lists | 24 h | `TOURS_CITIES_INTERVAL_MS`, `TOURS_CITY_LIMIT`, `TOURS_WIKIVOYAGE_DISTRICTS` |
++| `places` | Wikivoyage See places for every city, then Overpass `tourism=*` for the 10 cities whose OSM data is oldest | 7 d | `TOURS_PLACES_INTERVAL_MS`, `TOURS_OSM_RADIUS_M`, `TOURS_OSM_MAX_PER_CITY`, `TOURS_OSM_CITIES_PER_RUN` |
++| `routes` | up to two cited self-guided routes per city from the See lists | 7 d | `TOURS_ROUTES_INTERVAL_MS` |
++| `live` | IRL streams now, recent IRL VODs/clips | 2 min | `TOURS_LIVE_INTERVAL_MS` |
++| `stays` | Rent listings for the city | 6 h | `TOURS_STAYS_INTERVAL_MS` |
++
++`npm run seed` runs them outside the server; the first run's counts are in the report and in `tours_job_runs`.
++The first local seed (2026-10-31) stored **100 cities**, **2,714 places** (2,406 Wikivoyage + 308 OpenStreetMap across
++10 cities), **180 routes / 1,239 cited stops** (96 cities), **23 IRL VODs + 1 clip**, and **0 stays** (Rent had no
++active listing; the shelf says so). Live had no stream at that moment.
++A failed source leaves the previous rows alone and is reported honestly; a city with fewer than four cited stops
++with coordinates gets no invented route.
++
++## API
++
++Everything is JSON under `/api/v1`; errors are `application/problem+json` with a stable code. A person is the
++`tours_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.tours` (`403 tours.cross_site`). No route
++needs a service token.
++
++| Route | What it does |
++|---|---|
++| `GET /api/v1/cities?limit=&offset=&q=` | the top cities, population first, with place/route counts |
++| `GET /api/v1/cities/:slug` | one city with its places, routes, live tours and stays |
++| `GET /api/v1/places/:id` | one place and the routes it is on |
++| `GET /api/v1/routes/:id` | one route with its ordered, cited stops |
++| `GET /api/v1/live` | IRL tours now, recent IRL VODs and clips |
++| `GET /api/v1/openapi.json` | the OpenAPI 3.1 document |
++| `GET /api/v1/trips` | the signed-in person's trips |
++| `POST /api/v1/trips` | create `{ title, city_slug, starts_on, ends_on, notes?, visibility? }` |
++| `GET /api/v1/trips/:slug` | own trip, or a shared one read-only |
++| `PATCH /api/v1/trips/:slug` | edit title, city, dates, notes or visibility |
++| `DELETE /api/v1/trips/:slug` | delete the trip (204) |
++| `POST /api/v1/trips/:slug/places` | save a place `{ place_id, note? }` |
++| `DELETE /api/v1/trips/:slug/places/:placeId` | remove it |
++| `POST /api/v1/trips/:slug/route` | set/clear the route `{ route_id }` (must be in the trip's city) |
++| `POST /api/v1/trips/:slug/stays` | add a stay `{ stay_id }` |
++| `DELETE /api/v1/trips/:slug/stays/:stayId` | remove it |
++
++Validation refusals are `400 tours.invalid` or `400 tours.trip.invalid`; other codes: `tours.sign_in_required`,
++`tours.person_required`, `tours.cross_site`, `tours.city_not_found`, `tours.place_not_found`,
++`tours.route_not_found`, `tours.trip_not_found`, `tours.trip_owner`, `tours.rate_limited`.
++
++## Pages
++
++Server-rendered through the OpenVibe Frame (`openvibe-shared/shell`) and the room kit, readable without JavaScript.
++Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in person or a private trip is
++`private, no-store`; a shared trip is public and read-only. Ids and classes start with `tr-`.
++
++| Page | What it is |
++|---|---|
++| `/` | the room: hero, live tours now, place of the day, top cities, routes, stays, scenes, FAQ, developers |
++| `/cities` | the catalogue with a search box |
++| `/city/:slug` | summary, image licence, places, routes, live, stays and a trip/ flight start |
++| `/place/:id` | a place, its facts, OSM link, source, routes it is on, and save-to-trip |
++| `/routes`, `/routes/:id` | the route list; one route with its map, stops, citations and audio note |
++| `/live`, `/stays` | IRL tours now/VODs/clips; Rent stays with add-to-trip |
++| `/flights` | the form that links the official airline-neutral search; the provider note |
++| `/trips`, `/trips/new`, `/trips/:slug` | a person's trips; create; view/edit/share/delete |
++| `/developers` | the API table, OpenAPI link, sources and job receipts |
++| `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | discovery; trips and the API are excluded |
++
++The route map is MapLibre + OSM raster tiles from a CDN, loaded only on a route page; the same page always has the
++numbered SVG map and every stop in text. `public/css/tours.css` is a working layout, not the final design: the
++orchestrator designs every page.
++
++## Sign-in, trips and privacy
++
++A trip belongs to one Network `usr_…` subject. Only the owner can change it; a shared trip is readable by anyone
++with the link and editable only by the owner; a private trip is a 404 to anyone else. The share slug is random, not
++sequential. Account export returns the person's trips; account deletion erases the trips and everything saved on
++them (`server/identity/account-data.js`). No table holds a token, and `/auth/me` never returns one.
++
++## Run and test
++
++```sh
++npm install --include=dev
++npm test # PGlite, fixtures, no network; 10 files
++npm run test:pg # the same through PostgreSQL + PgBouncer
++npm run seed # the real sources into data/pglite (development)
++TOURS_CITY_LIMIT=10 npm run seed
++npm start # PORT 5170, loopback; DATABASE_URL or embedded PGlite in development
++```
++
++The tests cover the migration and its constraints; every source parser and job against fixture sources (including
++the Commons licence, OSM User-Agent, stable ids and idempotent reruns); the flights URL and no-affiliate rule; the
++accent's AA contrast in both themes; every API route and its refusals; the full trip CRUD and sharing rules; pages
++and their TouristTrip/TouristAttraction JSON-LD; sign-in with PKCE and session rules; and discovery.
++
++## Deploy
++
++`deploy/systemd/openvibe-tours.service` and `deploy/nginx/openvibe.tours.conf` are Meme's files with the names and
++port changed (5170, `/etc/openvibe/tours.env`, `/opt/openvibe.tours`); the forms zone covers `/trips`. The
++`.gitignore` is Meme's exact file. Not deployed by this change: the orchestrator creates the repo, the Contracts
++manifest, the Network client and the host.
++
++## Accent
++
++Room id `tours` in `openvibe-shared/rooms`: dark `#F98942` on `#0B0D12` is **8.04:1** and light `#AA3900` on white is
++**6.37:1** — AA in both themes (tested in `test/accent.test.js`).
++
++## License
++
++AGPL-3.0-only. See [LICENSE](LICENSE).
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..7ca5619
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,19 @@
++# Security
++
++Report a vulnerability to [email protected], not in a public issue.
++
++- **Tokens.** The Network access and refresh tokens live only in httpOnly cookies (`tours_at`, `tours_rt`); the flow
++ state and PKCE verifier in `tours_oauth` (httpOnly, path `/auth`, 10 minutes). No token is stored in the database,
++ rendered in a page or logged. `/auth/me` never returns one.
++- **Sessions.** A session token is a Network user token with audience `openvibe.network`; FedCM assertions, app and
++ service tokens are refused. A write made with the session cookie must come from `https://openvibe.tours` itself
++ (`403 tours.cross_site`).
++- **Trips.** A trip is the owner's Network `usr_…` subject; a private trip is a 404 to anyone else, a shared one is
++ read-only, and the share slug is random. Account deletion cascades.
++- **Outbound.** The source jobs fetch only the fixed public hosts in `server/config.js` (OpenVibe.Live/Rent,
++ Wikivoyage, Wikidata, Wikipedia, Commons, Overpass). No user-chosen URL is fetched. The flight form is a link, not
++ a fetch, and has no affiliate parameter.
++- **Open data.** Overpass gets a contact User-Agent, one bounded query per city, a per-host pace and no bulk
++ download. Keys are never needed for a source; a key that is not implemented stays off.
++- **Deploy.** `User=ubuntu`, loopback only, `/internal` and `/metrics` are 404 at nginx, and request bodies are
++ never logged.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..5a68879
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,71 @@
++{
++ "repository": "OpenVibers/OpenVibe.Tours",
++ "version": "0.1.0",
++ "stage": "product",
++ "code": true,
++ "wave": "W21",
++ "plan": "OpenVibe.Tours: go anywhere — fly, stay, walk, watch — from open data (OpenStreetMap, Wikivoyage, Wikidata), IRL tours on OpenVibe.Live, stays from OpenVibe.Rent, self-guided routes, a flight link-out and shareable trips",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
++ "port": "5170",
++ "domain": "openvibe.tours",
++ "contracts": "openvibe-contracts v0.236.0",
++ "sdk": "openvibe-sdk v0.70.0",
++ "shared": "openvibe-shared v4.31.0",
++ "packages": [
++ "openvibe-contracts v0.236.0",
++ "openvibe-sdk v0.70.0",
++ "openvibe-shared v4.31.0"
++ ],
++ "summary": "OpenVibe.Tours is the travel room. The top 100 cities come from Wikidata coordinates and Wikivoyage pages; places are OpenStreetMap tourism=* through one bounded Overpass query per city; self-guided routes are generated from Wikivoyage See lists, marked as generated, and cite every stop. IRL streams, VODs and clips come from OpenVibe.Live's public list, stays from OpenVibe.Rent's public listings. There is no flight API key in v1: the form links the official airline-neutral search with the city pair filled in and no affiliate. A signed-in person can make a trip with a city, dates, saved places, one route and stays, and share it read-only. No route needs a service token; the source jobs are off under TOURS_DRILL.",
++ "works": [
++ "A place database from OpenStreetMap tourism=*, with each OSM object URL and fetch time",
++ "The top 100 cities resolved through Wikidata (coordinates, population, country, image) and Wikivoyage (summary + See lists)",
++ "Wikimedia Commons image licence and credit on every city image",
++ "A page per city and per place with sources and TouristDestination/TouristAttraction JSON-LD",
++ "A deterministic place of the day, the same for everyone all day",
++ "Self-guided routes generated from Wikivoyage See lists, marked generated, with a cited note and page URL at every stop",
++ "MapLibre + OpenStreetMap tiles on a route page, with a complete SVG map, stop list and map links without JavaScript",
++ "IRL tours now, recent IRL VODs and clips from OpenVibe.Live; a city only when the public title or tags name one",
++ "Stays from OpenVibe.Rent's public listings, read-only, never a payment",
++ "A flight form that opens the official airline-neutral search with the city pair and dates, no affiliate",
++ "A signed-in trip: city, dates, saved places, one route, stays, shareable read-only; owner-only edit and delete",
++ "GET /api/v1/cities, /cities/:slug, /places/:id, /routes/:id, /live, trips CRUD and an OpenAPI 3.1 document",
++ "Server-rendered home with hero, live rail, place of the day, top cities, routes, stays, scenes, FAQ and developers",
++ "Network sign-in with PKCE S256 (tours_at/tours_rt/tours_oauth); account deletion erases trips",
++ "Every source job records status, counts and error in tours_job_runs; jobs are off under TOURS_DRILL",
++ "Discovery artifacts; trips and the API are excluded",
++ "Deploy files copied from Meme with the names and port changed"
++ ],
++ "notYet": [
++ "A flight API key or adapter: v1 is a link to the official neutral search, with no price claim and no affiliate",
++ "Duffel/Kiwi search: the seam exists but no adapter; a key alone leaves it off",
++ "Speech audio on route stops: OpenVibe.AI has no speech-synthesis route today",
++ "Opening-hours parsing (raw OSM value shown), offline route download, recorded chaptered tours, robot tours",
++ "Group voting, a trip diary, flight alerts, partner booking and payouts (v2/v3)",
++ "Any write to OpenVibe.Live or OpenVibe.Rent: both are read-only public APIs here",
++ "A city search beyond the top-100 catalogue"
++ ],
++ "capabilitiesRegistered": false,
++ "serviceManifestRegistered": false,
++ "deployed": false,
++ "placeholderInSites": false,
++ "features": [
++ "cities from Wikidata and Wikivoyage",
++ "places from OpenStreetMap tourism=*",
++ "self-guided routes from Wikivoyage See lists",
++ "MapLibre + OSM tiles",
++ "IRL live tours, VODs and clips",
++ "stays from OpenVibe.Rent",
++ "official flight search link-out, no affiliate",
++ "signed-in trips with a read-only share link",
++ "TouristTrip/TouristAttraction JSON-LD",
++ "OpenAPI 3.1",
++ "source jobs with receipts",
++ "Network sign-in",
++ "account deletion",
++ "discovery",
++ "deploy files"
++ ],
++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-tours.service and deploy/nginx/openvibe.tours.conf",
++ "updated": "2026-10-31"
++}
ddiff --git a/deploy/nginx/openvibe.tours.conf b/deploy/nginx/openvibe.tours.conf
nnew file mode 100644
iindex 0000000..85606c3
---- /dev/null
++++ b/deploy/nginx/openvibe.tours.conf
@@@ -0,0 +1,115 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.tours — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.tours.conf (ov-vhost-install) — keep them in sync and reload
++## nginx after changing it. The Node app (port 5170, systemd unit openvibe-tours) renders every page and serves
++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses
++## text.
++##
++## No proxy_cache: the app decides cacheability per response (public for the public pages and entries; private,
++## no-store for anything drawn for a signed-in person). Never log request bodies here.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=tours_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=tours_api:10m rate=5r/s;
++limit_req_zone $binary_remote_addr zone=tours_forms:10m rate=1r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.tours www.openvibe.tours;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.tours$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.tours;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.tours/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.tours/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.tours$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.tours;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.tours/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.tours/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.tours.access.log;
++ error_log /var/log/nginx/openvibe.tours.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=tours_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5170;
++ }
++
++ location /auth/ {
++ limit_req zone=tours_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5170;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=tours_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5170;
++ }
++
++ # ── Forms and other writes: a trip, a saved place, a route, a stay ──
++ location ~ ^/trips(/|$) {
++ limit_req zone=tours_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:5170;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5170/metrics) ──
++ location ~* ^/metrics(/|$) { return 404; }
++
++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:5170/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:5170;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-tours.service b/deploy/systemd/openvibe-tours.service
nnew file mode 100644
iindex 0000000..58b8a5f
---- /dev/null
++++ b/deploy/systemd/openvibe-tours.service
@@@ -0,0 +1,31 @@
++[Unit]
++Description=OpenVibe.Tours — go anywhere: fly, stay, walk, watch.
++# Network (sign-in, JWKS, account events) should be up first, but Tours degrades honestly without it: the public
++# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).
++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.tours
++EnvironmentFile=/etc/openvibe/tours.env
++Environment=NODE_ENV=production
++Environment=PORT=5170
++Environment=HOST=127.0.0.1
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.tours/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.Tours's own database is PostgreSQL on the host's data role; the code tree stays read-only.
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..f05dfa1
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -0,0 +1,298 @@
++-- phase: expand
++-- OpenVibe.Tours v1: a place database from OpenStreetMap, Wikivoyage and Wikidata; self-guided routes; live IRL
++-- tours from OpenVibe.Live; stays from OpenVibe.Rent; and a person's trips. Nothing here is a credential. Source
++-- rows are public open data with their source and fetch time; a trip is a person's own row (server/identity/account-data.js).
++
++-- ── Cities ────────────────────────────────────────────────────
++CREATE TABLE tours_cities (
++ id text COLLATE "C" PRIMARY KEY, -- cty_ + 26 Crockford base32 chars (stable from the Wikidata id)
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ name text NOT NULL,
++ country text NOT NULL,
++ country_code text COLLATE "C",
++ region text,
++ lat double precision NOT NULL,
++ lon double precision NOT NULL,
++ population bigint,
++ wikidata_id text COLLATE "C" NOT NULL UNIQUE, -- Q…
++ wikipedia_title text,
++ wikivoyage_title text,
++ summary text,
++ image_url text,
++ image_credit text,
++ image_license text,
++ image_license_url text,
++ source text COLLATE "C" NOT NULL DEFAULT 'wikidata', -- wikidata | wikivoyage
++ source_url text NOT NULL,
++ fetched_at timestamptz NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT tours_cities_id CHECK (id ~ '^cty_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_cities_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 90),
++ CONSTRAINT tours_cities_name CHECK (length(name) BETWEEN 1 AND 120),
++ CONSTRAINT tours_cities_country CHECK (length(country) BETWEEN 1 AND 120),
++ CONSTRAINT tours_cities_country_code CHECK (country_code IS NULL OR country_code ~ '^[A-Z]{2}$'),
++ CONSTRAINT tours_cities_lat CHECK (lat BETWEEN -90 AND 90),
++ CONSTRAINT tours_cities_lon CHECK (lon BETWEEN -180 AND 180),
++ CONSTRAINT tours_cities_population CHECK (population IS NULL OR population >= 0),
++ CONSTRAINT tours_cities_wikidata CHECK (wikidata_id ~ '^Q[1-9][0-9]{0,11}$'),
++ CONSTRAINT tours_cities_source CHECK (source IN ('wikidata', 'wikivoyage')),
++ CONSTRAINT tours_cities_source_url CHECK (source_url ~ '^https://')
++);
++CREATE INDEX tours_cities_population ON tours_cities (population DESC NULLS LAST);
++CREATE INDEX tours_cities_country ON tours_cities (country);
++
++-- The Wikivoyage wikitext a city's summary and See listings come from. Kept apart so the city row stays small.
++CREATE TABLE tours_city_sources (
++ city_id text COLLATE "C" PRIMARY KEY REFERENCES tours_cities (id) ON DELETE CASCADE,
++ wikivoyage_title text NOT NULL,
++ wikitext text NOT NULL,
++ source_url text NOT NULL,
++ fetched_at timestamptz NOT NULL,
++ CONSTRAINT tours_city_sources_url CHECK (source_url ~ '^https://')
++);
++
++-- ── Places ───────────────────────────────────────────────────
++CREATE TABLE tours_places (
++ id text COLLATE "C" PRIMARY KEY, -- plc_ + stable from osm_type:osm_id
++ city_id text COLLATE "C" NOT NULL REFERENCES tours_cities (id) ON DELETE CASCADE,
++ osm_type text COLLATE "C" NOT NULL, -- node | way | relation
++ osm_id text COLLATE "C" NOT NULL,
++ name text NOT NULL,
++ kind text COLLATE "C" NOT NULL, -- the tourism=* value
++ lat double precision NOT NULL,
++ lon double precision NOT NULL,
++ address text,
++ website text,
++ opening_hours text,
++ wheelchair text COLLATE "C", -- yes | no | limited | null
++ fee text COLLATE "C", -- yes | no | null
++ wikidata_id text COLLATE "C",
++ wikipedia text,
++ image_url text,
++ image_credit text,
++ image_license text,
++ description text,
++ source text COLLATE "C" NOT NULL DEFAULT 'openstreetmap',
++ source_url text NOT NULL,
++ fetched_at timestamptz NOT NULL,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT tours_places_id CHECK (id ~ '^plc_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_places_osm_type CHECK (osm_type IN ('node', 'way', 'relation')),
++ CONSTRAINT tours_places_osm_id CHECK (osm_id ~ '^[0-9]{1,20}$'),
++ CONSTRAINT tours_places_name CHECK (length(name) BETWEEN 1 AND 200),
++ CONSTRAINT tours_places_kind CHECK (kind ~ '^[a-z_]{2,40}$'),
++ CONSTRAINT tours_places_lat CHECK (lat BETWEEN -90 AND 90),
++ CONSTRAINT tours_places_lon CHECK (lon BETWEEN -180 AND 180),
++ CONSTRAINT tours_places_wheelchair CHECK (wheelchair IS NULL OR wheelchair IN ('yes', 'no', 'limited')),
++ CONSTRAINT tours_places_fee CHECK (fee IS NULL OR fee IN ('yes', 'no')),
++ CONSTRAINT tours_places_source CHECK (source IN ('openstreetmap', 'wikivoyage', 'wikidata')),
++ CONSTRAINT tours_places_source_url CHECK (source_url ~ '^https://'),
++ UNIQUE (osm_type, osm_id)
++);
++CREATE INDEX tours_places_city ON tours_places (city_id, kind);
++CREATE INDEX tours_places_wikidata ON tours_places (wikidata_id) WHERE wikidata_id IS NOT NULL;
++
++-- ── Self-guided routes ───────────────────────────────────────
++CREATE TABLE tours_routes (
++ id text COLLATE "C" PRIMARY KEY, -- rte_ + stable from city:wikipedia page:index
++ city_id text COLLATE "C" NOT NULL REFERENCES tours_cities (id) ON DELETE CASCADE,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ title text NOT NULL,
++ summary text,
++ source text COLLATE "C" NOT NULL DEFAULT 'wikivoyage',
++ source_url text NOT NULL,
++ fetched_at timestamptz NOT NULL,
++ distance_m integer NOT NULL DEFAULT 0,
++ duration_min integer NOT NULL DEFAULT 0,
++ stop_count integer NOT NULL DEFAULT 0,
++ generated boolean NOT NULL DEFAULT true,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT tours_routes_id CHECK (id ~ '^rte_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_routes_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 110),
++ CONSTRAINT tours_routes_title CHECK (length(title) BETWEEN 3 AND 160),
++ CONSTRAINT tours_routes_source CHECK (source IN ('wikivoyage')),
++ CONSTRAINT tours_routes_source_url CHECK (source_url ~ '^https://'),
++ CONSTRAINT tours_routes_distance CHECK (distance_m >= 0),
++ CONSTRAINT tours_routes_duration CHECK (duration_min >= 0),
++ CONSTRAINT tours_routes_stop_count CHECK (stop_count BETWEEN 0 AND 30)
++);
++CREATE INDEX tours_routes_city ON tours_routes (city_id);
++
++CREATE TABLE tours_route_stops (
++ route_id text COLLATE "C" NOT NULL REFERENCES tours_routes (id) ON DELETE CASCADE,
++ position integer NOT NULL,
++ place_id text COLLATE "C" REFERENCES tours_places (id) ON DELETE SET NULL,
++ title text NOT NULL,
++ note text,
++ lat double precision NOT NULL,
++ lon double precision NOT NULL,
++ source text COLLATE "C" NOT NULL DEFAULT 'wikivoyage',
++ source_url text NOT NULL,
++ audio_status text COLLATE "C" NOT NULL DEFAULT 'none', -- none | ready
++ audio_url text,
++ PRIMARY KEY (route_id, position),
++ CONSTRAINT tours_route_stops_position CHECK (position BETWEEN 1 AND 30),
++ CONSTRAINT tours_route_stops_title CHECK (length(title) BETWEEN 1 AND 200),
++ CONSTRAINT tours_route_stops_lat CHECK (lat BETWEEN -90 AND 90),
++ CONSTRAINT tours_route_stops_lon CHECK (lon BETWEEN -180 AND 180),
++ CONSTRAINT tours_route_stops_source CHECK (source IN ('wikivoyage')),
++ CONSTRAINT tours_route_stops_source_url CHECK (source_url ~ '^https://'),
++ CONSTRAINT tours_route_stops_audio CHECK (audio_status IN ('none', 'ready')),
++ CONSTRAINT tours_route_stops_audio_url CHECK ((audio_status = 'none' AND audio_url IS NULL) OR (audio_status = 'ready' AND audio_url IS NOT NULL AND audio_url ~ '^https://'))
++);
++
++-- ── Live tours now (OpenVibe.Live's public stream list) ──────
++CREATE TABLE tours_live (
++ id text COLLATE "C" PRIMARY KEY, -- liv_ + stable from kind:external_id
++ kind text COLLATE "C" NOT NULL, -- stream | vod
++ external_id text COLLATE "C" NOT NULL,
++ username text COLLATE "C" NOT NULL,
++ display_name text,
++ title text NOT NULL,
++ category text COLLATE "C",
++ thumbnail_url text,
++ viewer_count integer NOT NULL DEFAULT 0,
++ city_id text COLLATE "C" REFERENCES tours_cities (id) ON DELETE SET NULL,
++ city_name text,
++ url text NOT NULL,
++ started_at timestamptz,
++ ended_at timestamptz,
++ fetched_at timestamptz NOT NULL,
++ CONSTRAINT tours_live_id CHECK (id ~ '^liv_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_live_kind CHECK (kind IN ('stream', 'vod')),
++ CONSTRAINT tours_live_username CHECK (username ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$'),
++ CONSTRAINT tours_live_title CHECK (length(title) BETWEEN 1 AND 240),
++ CONSTRAINT tours_live_viewers CHECK (viewer_count >= 0),
++ CONSTRAINT tours_live_url CHECK (url ~ '^https://'),
++ UNIQUE (kind, external_id)
++);
++CREATE INDEX tours_live_kind ON tours_live (kind, fetched_at DESC);
++CREATE INDEX tours_live_city ON tours_live (city_id) WHERE city_id IS NOT NULL;
++
++-- ── Stays (OpenVibe.Rent's public listings) ──────────────────
++CREATE TABLE tours_stays (
++ id text COLLATE "C" PRIMARY KEY, -- sty_ + stable from the Rent listing id
++ external_id text COLLATE "C" NOT NULL UNIQUE, -- rnt_…
++ city_id text COLLATE "C" REFERENCES tours_cities (id) ON DELETE SET NULL,
++ city_name text NOT NULL,
++ title text NOT NULL,
++ kind text COLLATE "C" NOT NULL,
++ price numeric(12,2) NOT NULL,
++ currency text COLLATE "C" NOT NULL,
++ period text COLLATE "C" NOT NULL,
++ neighbourhood text,
++ url text NOT NULL,
++ fetched_at timestamptz NOT NULL,
++ CONSTRAINT tours_stays_id CHECK (id ~ '^sty_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_stays_external CHECK (external_id ~ '^rnt_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_stays_title CHECK (length(title) BETWEEN 1 AND 200),
++ CONSTRAINT tours_stays_kind CHECK (kind IN ('apartment', 'room', 'house', 'commercial', 'parking', 'equipment', 'other')),
++ CONSTRAINT tours_stays_price CHECK (price >= 0),
++ CONSTRAINT tours_stays_currency CHECK (currency ~ '^[A-Z]{3}$'),
++ CONSTRAINT tours_stays_period CHECK (period IN ('month', 'week', 'day', 'hour', 'once')),
++ CONSTRAINT tours_stays_url CHECK (url ~ '^https://')
++);
++CREATE INDEX tours_stays_city ON tours_stays (city_id);
++
++-- ── Trips (a signed-in person's own) ─────────────────────────
++CREATE TABLE tours_trips (
++ id text COLLATE "C" PRIMARY KEY, -- trp_ + ULID
++ slug text COLLATE "C" NOT NULL UNIQUE, -- the share link: a random 20-char suffix
++ owner_subject text COLLATE "C" NOT NULL, -- usr_…; never a token
++ title text NOT NULL,
++ city_id text COLLATE "C" REFERENCES tours_cities (id) ON DELETE SET NULL,
++ starts_on date NOT NULL,
++ ends_on date NOT NULL,
++ notes text,
++ visibility text COLLATE "C" NOT NULL DEFAULT 'private', -- private | shared
++ revision bigint NOT NULL DEFAULT 1,
++ created_at timestamptz NOT NULL DEFAULT now(),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ CONSTRAINT tours_trips_id CHECK (id ~ '^trp_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_trips_slug CHECK (slug ~ '^trip-[a-z0-9]{16,32}$'),
++ CONSTRAINT tours_trips_owner CHECK (owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
++ CONSTRAINT tours_trips_title CHECK (length(title) BETWEEN 3 AND 120),
++ CONSTRAINT tours_trips_dates CHECK (ends_on >= starts_on AND ends_on - starts_on <= 60),
++ CONSTRAINT tours_trips_notes CHECK (notes IS NULL OR length(notes) <= 2000),
++ CONSTRAINT tours_trips_visibility CHECK (visibility IN ('private', 'shared')),
++ CONSTRAINT tours_trips_revision CHECK (revision > 0)
++);
++CREATE INDEX tours_trips_owner ON tours_trips (owner_subject, starts_on DESC);
++CREATE INDEX tours_trips_shared ON tours_trips (slug) WHERE visibility = 'shared';
++
++CREATE TABLE tours_trip_places (
++ trip_id text COLLATE "C" NOT NULL REFERENCES tours_trips (id) ON DELETE CASCADE,
++ place_id text COLLATE "C" NOT NULL REFERENCES tours_places (id) ON DELETE CASCADE,
++ position integer NOT NULL DEFAULT 1,
++ note text,
++ added_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (trip_id, place_id),
++ CONSTRAINT tours_trip_places_position CHECK (position BETWEEN 1 AND 200),
++ CONSTRAINT tours_trip_places_note CHECK (note IS NULL OR length(note) <= 500)
++);
++
++CREATE TABLE tours_trip_routes (
++ trip_id text COLLATE "C" PRIMARY KEY REFERENCES tours_trips (id) ON DELETE CASCADE,
++ route_id text COLLATE "C" NOT NULL REFERENCES tours_routes (id) ON DELETE CASCADE,
++ added_at timestamptz NOT NULL DEFAULT now()
++);
++
++CREATE TABLE tours_trip_stays (
++ trip_id text COLLATE "C" NOT NULL REFERENCES tours_trips (id) ON DELETE CASCADE,
++ stay_id text COLLATE "C" NOT NULL REFERENCES tours_stays (id) ON DELETE CASCADE,
++ added_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (trip_id, stay_id)
++);
++
++-- ── Job runs (the home says honestly what was fetched and when) ──
++CREATE TABLE tours_job_runs (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ job text COLLATE "C" NOT NULL,
++ started_at timestamptz NOT NULL,
++ finished_at timestamptz,
++ status text COLLATE "C" NOT NULL DEFAULT 'running', -- running | ok | partial | failed
++ counts jsonb NOT NULL DEFAULT '{}'::jsonb,
++ error text,
++ CONSTRAINT tours_job_runs_status CHECK (status IN ('running', 'ok', 'partial', 'failed'))
++);
++CREATE INDEX tours_job_runs_job ON tours_job_runs (job, started_at DESC);
++
++-- ── The SDK's account receipts and request telemetry ────────
++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
++);
++
++CREATE TABLE telemetry_samples (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ at timestamptz NOT NULL,
++ service text NOT NULL,
++ project text,
++ subject text,
++ resource text,
++ provider text,
++ node text,
++ cell text,
++ region text,
++ operation text NOT NULL,
++ latency_ms double precision,
++ queue_delay_ms double precision,
++ ttfb_ms double precision,
++ throughput_per_second double precision,
++ bytes bigint,
++ status text,
++ cache_status text,
++ cost_estimate double precision,
++ route_epoch bigint,
++ trace_id text,
++ extra jsonb NOT NULL DEFAULT '{}'::jsonb
++);
++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
ddiff --git a/migrations/0002_places_sources.sql b/migrations/0002_places_sources.sql
nnew file mode 100644
iindex 0000000..8d2bee6
---- /dev/null
++++ b/migrations/0002_places_sources.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- Wikivoyage places (CC BY-SA) sit beside OpenStreetMap places, and OSM refreshes are rotated a few cities per run
++-- so one job never hammers the public Overpass instance. Clips are a third Live kind (openvibe.video/c/<id>).
++
++ALTER TABLE tours_cities ADD COLUMN osm_fetched_at timestamptz;
++
++ALTER TABLE tours_places DROP CONSTRAINT tours_places_osm_type;
++ALTER TABLE tours_places ADD CONSTRAINT tours_places_osm_type CHECK (osm_type IN ('node', 'way', 'relation', 'wikivoyage'));
++ALTER TABLE tours_places DROP CONSTRAINT tours_places_osm_id;
++ALTER TABLE tours_places ADD CONSTRAINT tours_places_osm_id CHECK (osm_id ~ '^[A-Za-z0-9:_-]{1,64}$');
++
++ALTER TABLE tours_live DROP CONSTRAINT tours_live_kind;
++ALTER TABLE tours_live ADD CONSTRAINT tours_live_kind CHECK (kind IN ('stream', 'vod', 'clip'));
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..9c5eda8
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1411 @@
++{
++ "name": "openvibe-tours",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-tours",
++ "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.236.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.14",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.14.tgz",
++ "integrity": "sha512-aAQe7iArcFB2w1xtx91l3l9trxhMzD6aZf+WnosSHNhwgp70EJz80Igw/bHnW14fUEFYWPBoQ1sGom8Xc0Jttg==",
++ "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.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "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.236.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
++ "integrity": "sha512-SPq+/t6ohdwW2DPqK9nZmtbeFex7q6KN/eQVc19MTQ30Df/m7A/idixZGcfs4I3Z43pUpLN8I4ylfOLlFShqsA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.70.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
++ "license": "MIT",
++ "bin": {
++ "openvibe-download": "bin/openvibe-download.js"
++ },
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.31.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.1",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.1",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.1",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.1"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.1",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.1",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.1",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..ac26d46
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,37 @@
++{
++ "name": "openvibe-tours",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Tours — go anywhere: fly, stay, walk, watch.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "seed": "node scripts/seed.js",
++ "test": "node test/run.js",
++ "test:pg": "TOURS_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/v0.236.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/public/css/tours.css b/public/css/tours.css
nnew file mode 100644
iindex 0000000..78e162a
---- /dev/null
++++ b/public/css/tours.css
@@@ -0,0 +1,129 @@
++/* OpenVibe.Tours: 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.
++ This is a working layout, not the final design: the orchestrator designs every page. Phone first. */
++*, *::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; }
++.tr { display: block; }
++.tr a { color: var(--room-strong, var(--accent-light, #60a5fa)); }
++.tr .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
++.tr-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
++.tr-narrow { max-width: 640px; }
++.tr-page-pad { padding-top: clamp(28px, 6vw, 64px); }
++.tr-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
++.tr-page-h h1, .tr h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
++.tr-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong, var(--accent-light, #60a5fa)); }
++.tr-lede { margin: 10px 0 0; max-width: 760px; color: var(--text-secondary, #96a7c2); line-height: 1.6; }
++.tr-muted { color: var(--text-muted, #7386a3); }
++.tr-fine { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
++.tr-more { font-weight: 700; font-size: .9rem; }
++.tr-sec { margin-top: clamp(26px, 4vw, 42px); }
++.tr-sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
++.tr-sec-head h2, .tr h2 { margin: 0; font-size: 1.25rem; font-weight: 800; letter-spacing: -.01em; }
++
++/* Hero drawing */
++.tr-vis { position: relative; width: min(100%, 420px); margin: 0 auto; }
++.tr-vis-svg { width: 100%; height: auto; display: block; }
++.tr-grid { stroke: color-mix(in srgb, var(--room-accent, #F98942) 22%, transparent); stroke-width: 1; }
++.tr-arc { opacity: .85; }
++.tr-land { fill: var(--room-accent, #F98942); }
++.tr-pin { fill: var(--room-accent, #F98942); stroke: var(--bg-primary, #0a0f1c); stroke-width: 2; }
++.tr-pin-n { fill: #0a0f1c; font-size: 9px; font-weight: 800; }
++.tr-walk { filter: drop-shadow(0 2px 8px rgba(0,0,0,.4)); }
++.tr-vis-badge { display: inline-block; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--room-strong, var(--accent-light, #60a5fa)); border: 1px solid var(--border, rgba(255,255,255,.12)); }
++
++/* Cards, lists, forms */
++.tr-card, .tr-form.tr-card { border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; background: var(--bg-secondary, #101828); padding: 18px; }
++.tr-form { display: grid; gap: 12px; margin: 0; }
++.tr-form-inline { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; margin-top: 10px; }
++.tr-field { display: grid; gap: 5px; font-size: .9rem; min-width: 0; }
++.tr-field > span { color: var(--text-secondary, #96a7c2); font-size: .8rem; font-weight: 700; }
++.tr-field input, .tr-field select, .tr-field textarea { width: 100%; padding: 9px 11px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.16)); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; }
++.tr-field input:focus-visible, .tr-field select:focus-visible, .tr-field textarea:focus-visible { outline: 2px solid var(--room-accent, #F98942); outline-offset: 1px; }
++.tr-btn { display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border-radius: 11px; border: 1px solid transparent; background: var(--room-accent, #F98942); color: var(--room-on, #0a0f1c); font-weight: 800; font-size: .95rem; text-decoration: none; cursor: pointer; }
++.tr-btn:hover { filter: brightness(1.06); }
++.tr-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7); border-color: var(--border, rgba(255,255,255,.18)); }
++.tr-link-btn { background: none; border: 0; padding: 0; margin: 4px 0 0; color: var(--text-muted, #7386a3); text-decoration: underline; cursor: pointer; font: inherit; font-size: .82rem; }
++.tr-inline-form { display: inline; }
++.tr-note { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.16)); }
++.tr-note-error { border-color: #b44444; color: #ffb4b4; }
++.tr-note-warn { border-color: #8a6a1f; color: #ffd98a; }
++.tr-empty { padding: 20px; border: 1px dashed var(--border, rgba(255,255,255,.18)); border-radius: 14px; color: var(--text-secondary, #96a7c2); }
++
++/* City / place / route lists */
++.tr-city-list, .tr-place-list, .tr-route-list, .tr-live-list, .tr-stay-list, .tr-trip-list, .tr-trip-places { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; }
++.tr-city-row a, .tr-place-row a, .tr-route-list a, .tr-live-list a, .tr-stay-list a, .tr-trip-list a { display: flex; gap: 12px; align-items: center; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }
++.tr-city-row a:hover, .tr-place-row a:hover, .tr-route-list a:hover, .tr-live-list a:hover, .tr-stay-list a:hover, .tr-trip-list a:hover { border-color: var(--room-accent, #F98942); }
++.tr-city-photo { width: 58px; height: 44px; border-radius: 8px; overflow: hidden; flex: none; display: grid; place-items: center; background: var(--bg-primary, #0a0f1c); color: var(--room-accent, #F98942); }
++.tr-city-photo img { width: 100%; height: 100%; object-fit: cover; }
++.tr-city-text, .tr-place-row span, .tr-route-list a, .tr-live-list a, .tr-stay-list a { display: grid; gap: 2px; }
++.tr-city-text strong, .tr-place-row strong, .tr-route-list strong, .tr-live-list strong, .tr-stay-list strong { font-size: 1rem; }
++.tr-city-img, .tr-place-img { margin: 16px 0 0; }
++.tr-city-img img, .tr-place-img img { width: 100%; max-height: 380px; object-fit: cover; border-radius: 16px; }
++.tr-city-img figcaption, .tr-place-img figcaption { font-size: .78rem; color: var(--text-muted, #7386a3); margin-top: 6px; }
++.tr-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; margin: 18px 0 0; }
++.tr-facts div { padding: 10px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-secondary, #101828); }
++.tr-facts dt { font-size: .74rem; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted, #7386a3); }
++.tr-facts dd { margin: 3px 0 0; overflow-wrap: anywhere; }
++
++/* Place of the day */
++.tr-pod { border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 18px; overflow: hidden; background: var(--bg-secondary, #101828); }
++.tr-pod-link { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); color: inherit; text-decoration: none; }
++.tr-pod-link img { width: 100%; height: 100%; min-height: 190px; object-fit: cover; }
++.tr-pod-noimg { display: grid; place-items: center; min-height: 190px; font-size: 2rem; color: var(--room-accent, #F98942); background: var(--bg-primary, #0a0f1c); }
++.tr-pod-body { display: grid; align-content: center; gap: 6px; padding: 18px; }
++.tr-pod-body strong { font-size: 1.5rem; line-height: 1.15; }
++
++/* Live */
++.tr-live-grid { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
++.tr-live-card { position: relative; display: grid; gap: 4px; padding: 10px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-secondary, #101828); color: inherit; text-decoration: none; }
++.tr-live-card img, .tr-live-noimg { width: 100%; aspect-ratio: 16/9; object-fit: cover; border-radius: 10px; background: var(--bg-primary, #0a0f1c); display: grid; place-items: center; color: #ef4444; }
++.tr-live-live { position: absolute; top: 16px; left: 16px; padding: 2px 8px; border-radius: 999px; background: #ef4444; color: #fff; font-size: .7rem; font-weight: 800; letter-spacing: .06em; }
++.tr-live-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: #ef4444; }
++
++/* Route map and stops */
++.tr-map { margin: 18px 0 0; }
++.tr-maplibre { height: min(56vh, 440px); border-radius: 16px; overflow: hidden; border: 1px solid var(--border, rgba(255,255,255,.12)); }
++.tr-map-fallback { margin: 0; }
++.tr-map-fallback-out { display: none; }
++.tr-map-bg { fill: var(--bg-secondary, #101828); }
++.tr-map-grid { stroke: var(--border, rgba(255,255,255,.12)); stroke-width: 1; }
++.tr-map-fallback figcaption { font-size: .8rem; color: var(--text-muted, #7386a3); margin-top: 6px; }
++.tr-map-pin { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: var(--room-accent, #F98942); color: #0a0f1c; font-weight: 800; font-size: 12px; border: 2px solid #0a0f1c; }
++.tr-stops { list-style: none; padding: 0; margin: 22px 0 0; display: grid; gap: 10px; }
++.tr-stop { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; padding: 14px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.1)); background: var(--bg-secondary, #101828); }
++.tr-stop-num { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; background: var(--room-accent, #F98942); color: #0a0f1c; font-weight: 800; font-size: .85rem; }
++.tr-stop-body h2 { font-size: 1.05rem; margin: 0 0 4px; }
++.tr-stop-body p { margin: 3px 0; line-height: 1.55; }
++.tr-audio { font-size: .8rem; }
++
++/* Trip */
++.tr-trip-places li { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 10px; padding: 10px 12px; border: 1px solid var(--border, rgba(255,255,255,.1)); border-radius: 12px; background: var(--bg-secondary, #101828); }
++.tr-trip-places li > div { display: grid; gap: 2px; }
++.tr-price { font-weight: 800; color: var(--room-strong, var(--accent-light, #60a5fa)); }
++.tr-jobs { margin: 8px 0 0; padding-left: 18px; color: var(--text-secondary, #96a7c2); font-size: .88rem; line-height: 1.6; }
++.tr-table-wrap { overflow-x: auto; border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 14px; }
++.tr-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
++.tr-table th, .tr-table td { padding: 9px 11px; text-align: left; border-bottom: 1px solid var(--border, rgba(255,255,255,.1)); vertical-align: top; }
++.tr-table thead th { background: var(--bg-secondary, #101828); font-size: .74rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted, #7386a3); }
++.tr-list { padding-left: 18px; line-height: 1.7; }
++.tr-card h2:first-child, .tr-card h3:first-child { margin-top: 0; }
++
++/* Demos in the room scenes */
++.tr-demo-walk, .tr-demo-live, .tr-demo-trip { display: grid; gap: 6px; padding: 16px; border-radius: 14px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f1c); }
++.tr-demo-walk small, .tr-demo-live small, .tr-demo-trip small { color: var(--text-muted, #7386a3); margin-top: 6px; }
++.tr-demo-stop { padding: 7px 10px; border-radius: 9px; background: var(--bg-secondary, #101828); border-left: 3px solid var(--room-accent, #F98942); }
++.tr-demo-line { width: 2px; height: 10px; margin-left: 16px; background: var(--room-accent, #F98942); opacity: .6; }
++.tr-demo-trip { grid-template-columns: 42px minmax(0, 1fr); align-items: center; }
++.tr-demo-trip small { grid-column: 1 / -1; }
++.tr-trip-day { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--room-strong, var(--accent-light, #60a5fa)); }
++
++@media (max-width: 680px) {
++ .tr-pod-link { grid-template-columns: 1fr; }
++ .tr-pod-link img, .tr-pod-noimg { min-height: 150px; max-height: 210px; }
++ .tr-form-inline { display: grid; grid-template-columns: 1fr; }
++}
++@media (prefers-reduced-motion: reduce) {
++ * { scroll-behavior: auto !important; }
++}
ddiff --git a/public/js/tours.js b/public/js/tours.js
nnew file mode 100644
iindex 0000000..155b670
---- /dev/null
++++ b/public/js/tours.js
@@@ -0,0 +1,54 @@
++'use strict';
++
++/**
++ * The route page's map. MapLibre + OpenStreetMap raster tiles load only when a route page is opened; without
++ * JavaScript, the SVG map and every stop are already in the page. Nothing here fetches a user URL.
++ */
++(function () {
++ function initMap() {
++ var wrap = document.getElementById('tr-map');
++ var mount = document.getElementById('tr-maplibre');
++ if (!wrap || !mount || !window.maplibregl) return;
++ var stops = [];
++ try { stops = JSON.parse(wrap.getAttribute('data-stops') || '[]'); } catch (e) { stops = []; }
++ if (!Array.isArray(stops) || stops.length < 2) return;
++ var lats = stops.map(function (s) { return Number(s.lat); }).filter(Number.isFinite);
++ var lons = stops.map(function (s) { return Number(s.lon); }).filter(Number.isFinite);
++ if (!lats.length || !lons.length) return;
++ var center = [(Math.min.apply(null, lons) + Math.max.apply(null, lons)) / 2, (Math.min.apply(null, lats) + Math.max.apply(null, lats)) / 2];
++ mount.hidden = false;
++ var map;
++ try {
++ map = new window.maplibregl.Map({
++ container: mount,
++ style: {
++ version: 8,
++ sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 19, attribution: '© OpenStreetMap contributors' } },
++ layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
++ },
++ center: center,
++ zoom: 13,
++ attributionControl: true,
++ });
++ } catch (e) { mount.hidden = true; return; }
++ map.addControl(new window.maplibregl.NavigationControl({ showCompass: false }), 'top-right');
++ var bounds = new window.maplibregl.LngLatBounds();
++ stops.forEach(function (s) {
++ var lat = Number(s.lat); var lon = Number(s.lon);
++ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return;
++ var el = document.createElement('div');
++ el.className = 'tr-map-pin';
++ el.textContent = String(s.n || '');
++ new window.maplibregl.Marker({ element: el }).setLngLat([lon, lat]).setPopup(new window.maplibregl.Popup({ offset: 18 }).setText(String(s.title || ''))).addTo(map);
++ bounds.extend([lon, lat]);
++ });
++ try { map.fitBounds(bounds, { padding: 42, maxZoom: 15, duration: 0 }); } catch (e) { /* the centre is set */ }
++ map.on('load', function () {
++ var fallback = document.getElementById('tr-map-fallback');
++ if (fallback) fallback.classList.add('tr-map-fallback-out');
++ });
++ }
++
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initMap);
++ else initMap();
++}());
ddiff --git a/scripts/seed.js b/scripts/seed.js
nnew file mode 100644
iindex 0000000..cd59326
---- /dev/null
++++ b/scripts/seed.js
@@@ -0,0 +1,44 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * First run / manual refresh of the source jobs, outside the server:
++ *
++ * node scripts/seed.js # all five jobs, the configured city limit
++ * node scripts/seed.js cities places # only these, in this order
++ * TOURS_CITY_LIMIT=20 node scripts/seed.js
++ *
++ * Development without DATABASE_URL uses the embedded PGlite database in data/pglite. The script never starts the
++ * HTTP server, never starts the job timers and never writes a secret to the database.
++ */
++process.env.NODE_ENV = process.env.NODE_ENV || 'development';
++process.env.TOURS_DRILL = '1';
++require('dotenv').config();
++
++const configLib = require('../server/config');
++const { openStore, createStore } = require('../server/db');
++const { createStore: dataStore } = require('../server/store');
++const { createJobs } = require('../server/jobs');
++
++const NAMES = ['cities', 'places', 'routes', 'live', 'stays'];
++
++(async () => {
++ const config = configLib.load(process.env);
++ const s = await openStore(config);
++ const store = dataStore(s, { config });
++ const jobs = createJobs({ config, s, store, fetchImpl: globalThis.fetch, log: console });
++ const wanted = process.argv.slice(2).filter((x) => NAMES.includes(x));
++ const names = wanted.length ? wanted : NAMES;
++ console.log(`[seed] ${names.join(', ')} · city limit ${config.openData.cityLimit} · db ${config.db.url ? 'PostgreSQL' : 'PGlite'}`);
++ try {
++ for (const name of names) {
++ const out = await jobs.runOnce(name);
++ console.log(`[seed] ${name}: ${JSON.stringify(out)}`);
++ }
++ console.log('[seed] counts:', JSON.stringify(await store.counts()));
++ const runs = await store.latestJobRuns();
++ for (const r of runs) console.log(`[seed] receipt ${r.job}: ${r.status} ${JSON.stringify(r.counts)}${r.error ? ` — ${String(r.error).slice(0, 300)}` : ''}`);
++ } finally {
++ await s.close();
++ }
++})().catch((err) => { console.error('[seed] failed:', err && err.stack || err); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..6d71861
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,163 @@
++'use strict';
++
++/**
++ * OpenVibe.Tours — Express app factory; server/index.js listens, tests build their own instance with a temp database
++ * and a mock Network/the open-data fixtures.
++ *
++ * /, /cities, /city/:slug, /place/:id, /routes, /routes/:id, /live, /stays, /flights,
++ * /trips, /trips/new, /trips/:slug, /developers, 404 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 machine endpoints
++ * /internal/events account deletion (loopback only)
++ *
++ * The source jobs are created here (ctx.jobs) but started by server/index.js, and never under TOURS_DRILL.
++ */
++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, createStore } = 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 { createStore: dataStore } = require('./store');
++const { createJobs } = require('./jobs');
++const accountDataLib = require('./identity/account-data');
++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, log, valkey, accountSend, 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 store = dataStore(s, { config });
++ const jobs = createJobs({ config, s, store, fetchImpl, log });
++
++ 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: Tours cannot answer account events'); });
++
++ const ctx = { config, s, keys, sso, principal, store, jobs, 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: 'tours', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'tours', release: release.release });
++ app.locals.metrics = metrics.registry;
++ ctx.telemetry = createHttpTelemetry({ service: 'tours', 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', 'https://unpkg.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://unpkg.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.live', 'https://openvibe.rent', 'https://upload.wikimedia.org', 'https://commons.wikimedia.org', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.live', 'https://openvibe.rent', 'https://*.tile.openstreetmap.org', 'https://unpkg.com'],
++ workerSrc: ["'self'", 'blob:'],
++ 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-tours', name: 'OpenVibe.Tours', 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: 'tours', service: 'tours', host: 'openvibe.tours', name: 'OpenVibe.Tours', profile: 'ugc' })); }
++
++ // ── OpenVibe.Events → this service (loopback only) ──────
++ 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.TOURS_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.TOURS_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.Tours]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return;
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..6dc8563
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,84 @@
++'use strict';
++
++/**
++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
++ * (sign-in) and a person's Bearer token (the API).
++ *
++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
++ * this module: token.no_key answers 'signing key not loaded yet'.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++function publicReason(err) {
++ const code = err && err.code;
++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
++}
++
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null;
++
++ async function refreshMirror() {
++ try {
++ const ks = await client.keys();
++ const first = ks && ks[0];
++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
++ } catch (err) { log.warn(`[OpenVibe.Tours] 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.Tours] 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.Tours] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ async function pemForToken(token) {
++ let kid = null;
++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
++ try {
++ const ks = await client.keysForKid(kid);
++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
++ } catch { return null; }
++ }
++
++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
++}
++
++module.exports = { createKeyStore, jwksUrl };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..94d8196
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,260 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `tours`.
++ *
++ * 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 Fun'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 = 'tours_at';
++const REFRESH_COOKIE = 'tours_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'tours_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.Tours] 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.Tours?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
++ });
++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
++ r.all('/logout', async (req, res) => {
++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (rt) {
++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
++ signal: AbortSignal.timeout(3000),
++ }).catch(() => {});
++ }
++ clearSession(res);
++ res.set('Clear-Site-Data', '"cache"');
++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
++ res.redirect(303, sanitizeNext(req.query.next, config));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ const c = req.cookies || {};
++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
++ const v = req.viewer;
++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..7bf71d1
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * OpenVibe.Tours configuration. Every value comes from the environment (production: /etc/openvibe/tours.env, see
++ * README). 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, 5170);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.tours' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ const duffelKey = String(env.TOURS_DUFFEL_API_KEY || '').trim();
++ const kiwiKey = String(env.TOURS_KIWI_API_KEY || '').trim();
++
++ return {
++ service: 'tours',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ drill: bool(env.TOURS_DRILL),
++ limits: {
++ minute: Math.max(1, int(env.TOURS_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.TOURS_LIMITS_HOUR, 3000)),
++ },
++
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.TOURS_PGLITE_DIR || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:tours:' },
++
++ networkUrl,
++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ audience: env.TOURS_AUDIENCE || 'openvibe.tours',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'tours',
++ 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 },
++
++ events: {
++ secrets: String(env.TOURS_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
++ url: trim(env.TOURS_EVENTS_URL || env.EVENTS_URL || ''),
++ endpoint: env.TOURS_EVENTS_ENDPOINT || '',
++ intervalMs: Math.max(500, int(env.TOURS_EVENTS_INTERVAL_MS, 5000)),
++ scope: env.TOURS_EVENTS_SCOPE || 'events.subscription.manage',
++ },
++
++ // The public APIs and open data Tours reads. Every request identifies itself, is paced per host and stores
++ // its source URL and fetch time. No key is required for any of them.
++ upstreams: {
++ live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
++ livePublic: trim(env.OV_LIVE_PUBLIC_URL || 'https://openvibe.live'),
++ video: trim(env.OV_VIDEO_PUBLIC_URL || 'https://openvibe.video'),
++ rent: trim(env.OV_RENT_URL || 'https://openvibe.rent'),
++ rentPublic: trim(env.OV_RENT_PUBLIC_URL || 'https://openvibe.rent'),
++ wikivoyage: trim(env.OV_WIKIVOYAGE_URL || 'https://en.wikivoyage.org'),
++ wikidata: trim(env.OV_WIKIDATA_URL || 'https://www.wikidata.org'),
++ wikipedia: trim(env.OV_WIKIPEDIA_URL || 'https://en.wikipedia.org'),
++ commons: trim(env.OV_COMMONS_URL || 'https://commons.wikimedia.org'),
++ overpass: trim(env.OV_OVERPASS_URL || 'https://overpass-api.de/api/interpreter'),
++ timeoutMs: Math.max(1000, int(env.TOURS_UPSTREAM_TIMEOUT_MS, 60_000)),
++ },
++
++ // OpenStreetMap/Overpass obligations: identify every request, pace it per host, bound the query by radius.
++ openData: {
++ userAgent: env.TOURS_USER_AGENT || `OpenVibeTours/0.1 (+${baseUrl}; [email protected])`,
++ email: String(env.TOURS_OSM_EMAIL || '').trim(),
++ minIntervalMs: Math.max(0, int(env.TOURS_MIN_INTERVAL_MS, 1100)),
++ retryAfterMs: Math.max(0, int(env.TOURS_RETRY_AFTER_MS, 120_000)),
++ radiusM: Math.min(10_000, Math.max(300, int(env.TOURS_OSM_RADIUS_M, 3500))),
++ maxPerCity: Math.min(200, Math.max(1, int(env.TOURS_OSM_MAX_PER_CITY, 80))),
++ osmCitiesPerRun: Math.min(100, Math.max(0, int(env.TOURS_OSM_CITIES_PER_RUN, 10))),
++ cityLimit: Math.min(200, Math.max(1, int(env.TOURS_CITY_LIMIT, 100))),
++ districtPages: Math.min(4, Math.max(0, int(env.TOURS_WIKIVOYAGE_DISTRICTS, 2))),
++ },
++
++ jobs: {
++ enabled: bool(env.TOURS_JOBS, true),
++ intervalMs: Math.max(60_000, int(env.TOURS_JOBS_INTERVAL_MS, 6 * 60 * 60 * 1000)),
++ citiesEveryMs: Math.max(60_000, int(env.TOURS_CITIES_INTERVAL_MS, 24 * 60 * 60 * 1000)),
++ placesEveryMs: Math.max(60_000, int(env.TOURS_PLACES_INTERVAL_MS, 7 * 24 * 60 * 60 * 1000)),
++ routesEveryMs: Math.max(60_000, int(env.TOURS_ROUTES_INTERVAL_MS, 7 * 24 * 60 * 60 * 1000)),
++ liveEveryMs: Math.max(30_000, int(env.TOURS_LIVE_INTERVAL_MS, 2 * 60 * 1000)),
++ staysEveryMs: Math.max(60_000, int(env.TOURS_STAYS_INTERVAL_MS, 6 * 60 * 60 * 1000)),
++ },
++
++ // No flight API key is configured in v1. The form builds the airline-neutral official search link and the
++ // Duffel/Kiwi seam stays off; a key alone does not turn a provider on until an adapter exists.
++ flights: {
++ provider: env.TOURS_FLIGHTS_PROVIDER || 'none',
++ duffel: { enabled: Boolean(duffelKey), key: duffelKey },
++ kiwi: { enabled: Boolean(kiwiKey), key: kiwiKey },
++ searchBase: trim(env.TOURS_FLIGHTS_SEARCH_URL || 'https://www.google.com/travel/flights'),
++ },
++
++ // OpenVibe.AI has no speech-synthesis route today (GET /api/v1/... has chat, generate, summarize, classify,
++ // extract, embed, image and research). Route stops therefore carry audio_status "none" and a plain note.
++ tts: { enabled: false, aiUrl: trim(env.OV_AI_URL || 'https://openvibe.ai') },
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..b75a3fd
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * OpenVibe.Tours' own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
++ * Source rows are public open data with their source and fetch time; a trip is a person's own row.
++ */
++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');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[OpenVibe.Tours] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'tours', 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: 'tours-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'tours', registry, log });
++}
++
++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
++function createStore(db, { now = () => Date.now() } = {}) {
++ return {
++ db,
++ now,
++ iso: () => new Date(now()).toISOString(),
++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
++ tx: async (fn) => await db.tx((t) => fn(t)),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS };
ddiff --git a/server/flights.js b/server/flights.js
nnew file mode 100644
iindex 0000000..828a038
---- /dev/null
++++ b/server/flights.js
@@@ -0,0 +1,55 @@
++'use strict';
++
++/**
++ * Flights in v1 have no provider key. The form builds the airline-neutral official search link with the city pair
++ * and the dates filled in; it never takes a commission and never claims a price it has not fetched. The Duffel and
++ * Kiwi seams are reported off (a key alone does not turn an adapter on), so a later provider can replace
++ * `searchUrl` without changing the page or the API.
++ */
++const isDay = (v) => /^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) && !Number.isNaN(Date.parse(`${v}T00:00:00Z`));
++const clean = (v, max = 80) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++
++function validate({ from, to, depart, return: ret } = {}) {
++ const a = clean(from); const b = clean(to);
++ if (a.length < 2 || b.length < 2) return { ok: false, message: 'from and to must name a city' };
++ if (!isDay(depart)) return { ok: false, message: 'depart must be a date (YYYY-MM-DD)' };
++ if (ret && !isDay(ret)) return { ok: false, message: 'return must be a date (YYYY-MM-DD)' };
++ if (ret && ret < depart) return { ok: false, message: 'return must not be before depart' };
++ return { ok: true, value: { from: a, to: b, depart, return: ret || null } };
++}
++
++/** The official airline-neutral search, with the city pair and dates in the query. No affiliate parameter. */
++function searchUrl({ from, to, depart, return: ret } = {}, config = {}) {
++ const base = String((config.flights && config.flights.searchBase) || 'https://www.google.com/travel/flights');
++ const text = ret
++ ? `Flights from ${from} to ${to} on ${depart} through ${ret}`
++ : `Flights from ${from} to ${to} on ${depart}`;
++ return `${base}?${new URLSearchParams({ q: text, hl: 'en' })}`;
++}
++
++function createFlights({ config }) {
++ const providers = {
++ duffel: Boolean(config.flights.duffel.enabled),
++ kiwi: Boolean(config.flights.kiwi.enabled),
++ search: 'google-flights',
++ };
++ const enabled = false; // no adapter exists in v1; a key alone must not look like a live price feed
++
++ function plan(input) {
++ const check = validate(input);
++ if (!check.ok) return check;
++ return {
++ ok: true,
++ ...check.value,
++ enabled,
++ provider: 'google-flights',
++ providers,
++ url: searchUrl(check.value, config),
++ note: 'Opens the official airline-neutral flight search in a new tab. Tours takes no commission and shows no price it has not fetched.',
++ };
++ }
++
++ return { plan, validate, searchUrl, providers, enabled };
++}
++
++module.exports = { createFlights, validate, searchUrl };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..8cd281e
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,215 @@
++'use strict';
++
++/**
++ * /api/v1 — OpenVibe.Tours' JSON API.
++ *
++ * GET /cities?limit=&offset=&q= the catalogue
++ * GET /cities/:slug one city, its places, routes, live and stays
++ * GET /places/:id one place and the routes it is on
++ * GET /routes/:id one route with its ordered stops
++ * GET /live IRL tours now and recent VODs
++ * GET /openapi.json the OpenAPI 3.1 document
++ * GET /trips the signed-in person's trips
++ * POST /trips create one
++ * GET /trips/:slug own, or shared read-only
++ * PATCH /trips/:slug edit own
++ * DELETE /trips/:slug delete own
++ * POST /trips/:slug/places save a place
++ * DELETE /trips/:slug/places/:placeId remove it
++ * POST /trips/:slug/route set or clear the route
++ * POST /trips/:slug/stays add a stay
++ * DELETE /trips/:slug/stays/:stayId remove it
++ *
++ * Errors are application/problem+json with a stable code. A person is the tours_at session cookie or a Network
++ * user token as a Bearer; a session write must come from openvibe.tours itself (403 tours.cross_site). No route
++ * needs a service token.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const { sameOrigin } = require('./principal');
++const { document: openapi } = require('../openapi');
++
++function createApi(ctx) {
++ const { config, s, principal, store } = 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);
++
++ 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, 'tours.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, 'tours.invalid', field ? `${field}: ${detail}` : detail);
++ const refuse = (res, out) => problem(res, out.status || 400, out.code || 'tours.invalid', out.message || 'invalid request');
++ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++
++ function person(req, res) {
++ const p = req.principal;
++ if (p.kind === 'anonymous') { problem(res, 401, 'tours.sign_in_required', 'Sign in at openvibe.tours, or send a person\'s Network token as a Bearer.'); return null; }
++ if (p.kind !== 'user') { problem(res, 403, 'tours.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, 'tours.cross_site', 'A signed-in request that changes something must come from openvibe.tours itself.');
++ return null;
++ }
++ return p;
++ }
++
++ const okPublic = (res, maxAge = 60) => res.set('Cache-Control', `public, max-age=${maxAge}`);
++
++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
++
++ // ── Public open data ─────────────────────────────────────
++ r.get('/cities', async (req, res) => {
++ const limit = req.query.limit == null || req.query.limit === '' ? 100 : asInt(req.query.limit, 100);
++ const offset = req.query.offset == null || req.query.offset === '' ? 0 : asInt(req.query.offset, 0);
++ if (!Number.isFinite(limit) || limit < 1 || limit > 200) return invalid(res, 'limit must be from 1 to 200', 'limit');
++ if (!Number.isFinite(offset) || offset < 0) return invalid(res, 'offset must be 0 or more', 'offset');
++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : null;
++ const cities = await store.listCities({ limit, offset, q });
++ okPublic(res);
++ return res.json({ count: cities.length, cities });
++ });
++
++ r.get('/cities/:slug', async (req, res) => {
++ const city = await store.cityBySlug(String(req.params.slug || ''));
++ if (!city) return problem(res, 404, 'tours.city_not_found', 'No such city.');
++ const [places, routes, live, stays] = await Promise.all([
++ store.cityPlaces(city.id, { limit: 100 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
++ ]);
++ okPublic(res);
++ return res.json({ city, places, routes, live, stays });
++ });
++
++ r.get('/places/:id', async (req, res) => {
++ const place = await store.placeById(String(req.params.id || ''));
++ if (!place) return problem(res, 404, 'tours.place_not_found', 'No such place.');
++ const routes = await store.routesForPlace(place.id);
++ okPublic(res);
++ return res.json({ place, routes });
++ });
++
++ r.get('/routes/:id', async (req, res) => {
++ const route = await store.routeById(String(req.params.id || ''));
++ if (!route) return problem(res, 404, 'tours.route_not_found', 'No such route.');
++ okPublic(res);
++ return res.json({ route });
++ });
++
++ r.get('/live', async (_req, res) => {
++ const [live, recent] = await Promise.all([store.liveNow({ limit: 50 }), store.recentTours({ limit: 25 })]);
++ okPublic(res, 30);
++ return res.json({
++ live, recent, source: 'OpenVibe.Live public stream list',
++ fetched_at: (live[0] || recent[0] || {}).fetched_at || null,
++ });
++ });
++
++ r.get('/openapi.json', (_req, res) => {
++ res.set('Cache-Control', 'public, max-age=300');
++ return res.json(openapi);
++ });
++
++ // ── Trips ───────────────────────────────────────────────
++ r.get('/trips', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const trips = await store.tripsForSubject(p.subject);
++ return res.json({ count: trips.length, trips });
++ });
++
++ r.post('/trips', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.createTrip({
++ subject: p.subject, title: b.title, citySlug: b.city_slug || b.city,
++ startsOn: b.starts_on, endsOn: b.ends_on, notes: b.notes, visibility: b.visibility,
++ });
++ if (!out.ok) return refuse(res, out);
++ res.set('Location', `/api/v1/trips/${out.trip.slug}`);
++ return res.status(201).json(out.trip);
++ });
++
++ r.get('/trips/:slug', async (req, res) => {
++ const subject = req.principal.kind === 'user' ? req.principal.subject : null;
++ const out = await store.tripBySlug(String(req.params.slug || ''), { subject });
++ if (!out) return problem(res, 404, 'tours.trip_not_found', 'No such trip, or it is private.');
++ res.set('Cache-Control', out.mine ? 'private, no-store' : 'public, max-age=60');
++ return res.json({ trip: out.trip, mine: out.mine, places: out.places, route: out.route, stays: out.stays });
++ });
++
++ r.patch('/trips/:slug', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const out = await store.updateTrip({ subject: p.subject, slug: String(req.params.slug || ''), input: req.body && typeof req.body === 'object' ? req.body : {} });
++ if (!out.ok) return refuse(res, out);
++ return res.json(out.trip);
++ });
++
++ r.delete('/trips/:slug', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const out = await store.deleteTrip({ subject: p.subject, slug: String(req.params.slug || '') });
++ if (!out.ok) return refuse(res, out);
++ return res.status(204).end();
++ });
++
++ r.post('/trips/:slug/places', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ if (!b.place_id) return invalid(res, 'place_id is required', 'place_id');
++ const out = await store.addTripPlace({ subject: p.subject, slug: String(req.params.slug || ''), placeId: b.place_id, note: b.note });
++ if (!out.ok) return refuse(res, out);
++ return res.status(201).json(out.place);
++ });
++
++ r.delete('/trips/:slug/places/:placeId', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const out = await store.removeTripPlace({ subject: p.subject, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
++ if (!out.ok) return refuse(res, out);
++ return res.status(204).end();
++ });
++
++ r.post('/trips/:slug/route', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const out = await store.setTripRoute({ subject: p.subject, slug: String(req.params.slug || ''), routeId: b.route_id || null });
++ if (!out.ok) return refuse(res, out);
++ return res.json({ ok: true });
++ });
++
++ r.post('/trips/:slug/stays', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ if (!b.stay_id) return invalid(res, 'stay_id is required', 'stay_id');
++ const out = await store.addTripStay({ subject: p.subject, slug: String(req.params.slug || ''), stayId: b.stay_id });
++ if (!out.ok) return refuse(res, out);
++ return res.status(201).json(out.stay);
++ });
++
++ r.delete('/trips/:slug/stays/:stayId', async (req, res) => {
++ const p = person(req, res); if (!p) return undefined;
++ const out = await store.removeTripStay({ subject: p.subject, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
++ if (!out.ok) return refuse(res, out);
++ return res.status(204).end();
++ });
++
++ return r;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..2ffc844
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,127 @@
++'use strict';
++
++/**
++ * Crawl artifacts for openvibe.tours: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public cities, places and
++ * routes are for search engines and language models; sign-in, the API, a trip (private or shared) and the internal
++ * routes are not.
++ */
++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.Tours';
++const DESCRIPTION = 'OpenVibe.Tours is go anywhere: fly, stay, walk, watch. Cities, places and self-guided routes from OpenStreetMap, Wikivoyage and Wikidata; IRL tours live on OpenVibe.Live; stays from OpenVibe.Rent; flights through the official airline-neutral search; and trips a person can share read-only.';
++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/trips'];
++
++const 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, searchUrl: `${site}/cities?q={q}` }),
++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'TravelApplication', keywords: 'openvibe tours, travel, places, self-guided routes, live tours, stays, flights, openstreetmap, wikivoyage, wikidata' }),
++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ seo.jsonLd.faq([
++ { q: 'Where does OpenVibe.Tours get its data?', a: 'OpenStreetMap (ODbL), Wikivoyage (CC BY-SA), Wikidata (CC0), OpenVibe.Live\'s public stream list and OpenVibe.Rent\'s public listings. Every item carries its source and the time it was fetched.' },
++ { q: 'Are the routes guided tours?', a: 'No. They are self-guided walks generated from Wikivoyage See lists and marked as generated, with a cited note at every stop.' },
++ { q: 'Does OpenVibe.Tours sell flights or stays?', a: 'No. There is no flight API key in v1: the flight form links the official airline-neutral search with no affiliate parameter. Stays link to OpenVibe.Rent; no payment is handled on Tours.' },
++ ]),
++ ];
++}
++
++function publicPages() {
++ return [
++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Tours home', text: 'Go anywhere: fly, stay, walk, watch.' },
++ { path: '/cities', changefreq: 'daily', priority: 0.9, title: 'Cities', text: 'The top 100 cities from Wikidata and Wikivoyage.' },
++ { path: '/routes', changefreq: 'daily', priority: 0.9, title: 'Self-guided routes', text: 'Walks generated from Wikivoyage See lists, with cited stops and OpenStreetMap maps.' },
++ { path: '/live', changefreq: 'hourly', priority: 0.8, title: 'Live tours now', text: 'IRL tours live on OpenVibe.Live.' },
++ { path: '/stays', changefreq: 'daily', priority: 0.7, title: 'Stays', text: 'Active rentals from OpenVibe.Rent.' },
++ { path: '/flights', changefreq: 'weekly', priority: 0.6, title: 'Flights', text: 'The official airline-neutral flight search, with no affiliate.' },
++ { path: '/developers', changefreq: 'weekly', priority: 0.5, title: 'The OpenVibe.Tours API', text: 'The JSON API, the OpenAPI document, and the sources behind every place.' },
++ ];
++}
++
++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.tours: public cities, places and routes are for search engines; sign-in, the API and trips 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: DESCRIPTION,
++ details: 'GET /api/v1/cities?limit=&offset=&q= lists the top cities; GET /api/v1/cities/:slug adds its places, routes, live tours and stays. GET /api/v1/places/:id and GET /api/v1/routes/:id return one place or one route with its ordered stops. GET /api/v1/live returns IRL tours now and recent IRL VODs. Trips are a signed-in person\'s own: the tours_at session cookie or a Network Bearer token. Every place is OpenStreetMap (ODbL) with its osm.org link and fetch time; route stops cite the Wikivoyage page (CC BY-SA); city images say their Commons licence. No route needs a service token. Flights have no API key in v1; the form only links the official airline-neutral search, with no affiliate.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'OpenVibe.Tours', url: abs('/'), note: 'go anywhere: fly, stay, walk, watch' },
++ { title: 'Cities', url: abs('/cities') },
++ { title: 'Routes', url: abs('/routes') },
++ { title: 'Live tours', url: abs('/live') },
++ { title: 'The API', url: abs('/developers') },
++ { title: 'OpenAPI', url: abs('/api/v1/openapi.json') },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'Cities (JSON)', url: abs('/api/v1/cities') },
++ { title: 'Live (JSON)', url: abs('/api/v1/live') },
++ { 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 IRL streams' },
++ { title: 'OpenVibe.Rent', url: 'https://openvibe.rent', note: 'the listings' },
++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and sign-in' },
++ ] },
++ ],
++ }));
++ });
++
++ r.get('/llms-full.txt', async (_req, res) => {
++ const [cities, routes, places] = await Promise.all([
++ store.publicCitiesForLlms(200), store.publicRoutesForSitemap(500), store.publicPlacesForSitemap(5000),
++ ]);
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
++ site: SITE_NAME,
++ summary: 'Every public city, route and place page on OpenVibe.Tours, from OpenStreetMap, Wikivoyage and Wikidata.',
++ base: site, maxBytes: 512 * 1024,
++ sections: [
++ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
++ { title: 'Cities', pages: cities.map((c) => ({ url: `/city/${c.slug}`, title: c.name, text: [c.country, c.summary].filter(Boolean).join(' · ') })) },
++ { title: 'Routes', pages: routes.map((r0) => ({ url: `/routes/${r0.id}`, title: 'Self-guided route', text: 'Generated from a Wikivoyage See list.' })) },
++ { title: 'Places', pages: places.map((p) => ({ url: `/place/${p.id}`, title: 'Place', text: 'From OpenStreetMap, ODbL.' })) },
++ ],
++ }));
++ });
++
++ r.get('/sitemap.xml', async (_req, res) => {
++ const lastmod = siteUpdated();
++ const [cities, routes, places] = await Promise.all([
++ store.publicCitiesForSitemap(500), store.publicRoutesForSitemap(5000), store.publicPlacesForSitemap(20000),
++ ]);
++ const urls = [
++ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
++ ...cities.map((e) => ({ loc: abs(`/city/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.8 })),
++ ...routes.map((e) => ({ loc: abs(`/routes/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.6 })),
++ ...places.map((e) => ({ loc: abs(`/place/${e.id}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'monthly', priority: 0.4 })),
++ ];
++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
++ });
++
++ return r;
++}
++
++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..4a46d71
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,312 @@
++'use strict';
++
++/**
++ * Tours' server-rendered pages. Everything works without JavaScript: the trip forms are plain POSTs and every place
++ * has its OpenStreetMap link. Public pages are public, max-age=60; a page drawn for a signed-in person or a private
++ * trip is private, no-store; a shared trip is readable by anyone with the link.
++ */
++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 views = require('../views');
++const { createFlights } = require('../flights');
++
++const PUBLIC_CACHE = 'public, max-age=60';
++
++function createPageRoutes(ctx) {
++ const { config, store } = 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 flights = createFlights({ config });
++
++ const notFromHere = (req, res) => page(req, res, {
++ title: 'Not from this site',
++ body: raw('<div class="tr-wrap tr-page-pad tr-narrow"><div class="tr-empty"><h1>That came from somewhere else</h1><p class="tr-muted">Use the form on <a href="/">OpenVibe.Tours</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]) : '']));
++
++ function tripJsonLd(trip, route) {
++ return {
++ '@context': 'https://schema.org', '@type': 'TouristTrip', name: trip.title,
++ description: trip.notes || `${trip.title} in ${trip.city_name || trip.city_slug || ''}, ${trip.starts_on} to ${trip.ends_on}.`,
++ url: `${base()}/trips/${trip.slug}`, touristType: 'Independent',
++ ...(route ? { itinerary: { '@type': 'ItemList', numberOfItems: route.stops.length, itemListElement: route.stops.map((s, i) => ({ '@type': 'ListItem', position: i + 1, item: { '@type': 'TouristAttraction', name: s.title, geo: { '@type': 'GeoCoordinates', latitude: s.lat, longitude: s.lon } } })) } } : {}),
++ };
++ }
++
++ // ── Home ─────────────────────────────────────────────────
++ r.get('/', async (req, res) => {
++ const [counts, live, recent, placeOfDay, cities, routes, stays, jobs] = await Promise.all([
++ store.counts(), store.liveNow({ limit: 8 }), store.recentTours({ limit: 8 }), store.placeOfTheDay(new Date().toISOString().slice(0, 10)),
++ store.listCities({ limit: 8 }), store.routes({ limit: 8 }), store.stays({ limit: 8 }), store.latestJobRuns(),
++ ]);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: null,
++ description: 'Go anywhere: fly, stay, walk, watch. Cities and places from OpenStreetMap, Wikivoyage and Wikidata, IRL tours on OpenVibe.Live, stays from OpenVibe.Rent, self-guided routes, and trips you can share read-only.',
++ jsonLd: homeJsonLd(config),
++ body: raw(views.home({ counts, live, recent, placeOfDay, cities, routes, stays, jobs })),
++ });
++ });
++
++ // ── Cities ───────────────────────────────────────────────
++ r.get('/cities', async (req, res) => {
++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 80) : '';
++ const cities = await store.listCities({ limit: 100, q: q || null });
++ return page(req, res, {
++ index: true, cache: q ? null : PUBLIC_CACHE, title: 'Cities',
++ description: 'The top 100 cities from Wikidata and Wikivoyage, with places from OpenStreetMap, self-guided routes, live tours and stays.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Cities on OpenVibe.Tours', url: `${base()}/cities`, description: 'The top 100 cities from open data.', siteUrl: base() })],
++ body: raw(views.citiesPage({ cities, q })),
++ });
++ });
++
++ r.get('/city/:slug', async (req, res) => {
++ const city = await store.cityBySlug(String(req.params.slug || ''));
++ if (!city) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
++ const [places, routes, live, stays] = await Promise.all([
++ store.cityPlaces(city.id, { limit: 60 }), store.cityRoutes(city.id), store.cityLive(city.id), store.cityStays(city.id),
++ ]);
++ const attractions = places.slice(0, 10).map((p) => ({
++ '@type': 'TouristAttraction', name: p.name, url: `${base()}/place/${p.id}`,
++ geo: { '@type': 'GeoCoordinates', latitude: p.lat, longitude: p.lon },
++ ...(p.description ? { description: p.description } : {}), ...(p.image_url ? { image: p.image_url } : {}),
++ }));
++ const tripLd = routes.slice(0, 3).map((rt) => ({
++ '@type': 'TouristTrip', name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary || undefined,
++ itinerary: { '@type': 'ItemList', numberOfItems: rt.stop_count },
++ }));
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: city.name,
++ description: city.summary ? String(city.summary).slice(0, 180) : `${city.name}: places, self-guided routes, live tours and stays.`,
++ jsonLd: [
++ { '@context': 'https://schema.org', '@type': 'TouristDestination', name: city.name, url: `${base()}/city/${city.slug}`, description: city.summary || undefined, geo: { '@type': 'GeoCoordinates', latitude: city.lat, longitude: city.lon }, touristType: 'Independent', ...(city.image_url ? { image: city.image_url } : {}) },
++ ...(attractions.length ? [{ '@context': 'https://schema.org', '@type': 'ItemList', name: `Places in ${city.name}`, numberOfItems: attractions.length, itemListElement: attractions.map((a, i) => ({ '@type': 'ListItem', position: i + 1, item: a })) }] : []),
++ ...tripLd,
++ seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: city.name, url: `${base()}/city/${city.slug}` }]),
++ ],
++ body: raw(views.cityPage({ city, places, routes, live, stays })),
++ });
++ });
++
++ // ── Places ───────────────────────────────────────────────
++ r.get('/place/:id', async (req, res) => {
++ const place = await store.placeById(String(req.params.id || ''));
++ if (!place) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
++ const [routeList, trips] = await Promise.all([
++ store.routesForPlace(place.id),
++ signedIn(req) ? store.tripsForSubject(req.viewer.subject) : Promise.resolve([]),
++ ]);
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: place.name,
++ description: place.description || `${place.name} in ${place.city_name}: coordinates, address, opening hours, source and map.`,
++ jsonLd: [
++ {
++ '@context': 'https://schema.org', '@type': 'TouristAttraction', name: place.name,
++ url: `${base()}/place/${place.id}`, description: place.description || undefined,
++ geo: { '@type': 'GeoCoordinates', latitude: place.lat, longitude: place.lon },
++ ...(place.address ? { address: { '@type': 'PostalAddress', streetAddress: place.address, addressLocality: place.city_name } } : {}),
++ ...(place.image_url ? { image: place.image_url } : {}),
++ ...(place.fee === 'no' ? { isAccessibleForFree: true } : place.fee === 'yes' ? { isAccessibleForFree: false } : {}),
++ sameAs: [place.osm_url],
++ },
++ seo.jsonLd.breadcrumbs([{ name: 'Cities', url: `${base()}/cities` }, { name: place.city_name, url: `${base()}/city/${place.city_slug}` }, { name: place.name, url: `${base()}/place/${place.id}` }]),
++ ],
++ body: raw(views.placePage({ place, routeList, trips })),
++ });
++ });
++
++ // ── Routes ───────────────────────────────────────────────
++ r.get('/routes', async (req, res) => {
++ const routes = await store.routes({ limit: 60 });
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Self-guided routes',
++ description: 'Self-guided walks generated from Wikivoyage See lists, with cited stops, coordinates and OpenStreetMap maps.',
++ jsonLd: [seo.jsonLd.itemList('Self-guided routes on OpenVibe.Tours', routes.map((rt) => ({ name: rt.title, url: `${base()}/routes/${rt.id}`, description: rt.summary })), { url: `${base()}/routes` })],
++ body: raw(`<div class="tr-wrap" id="tr-routes-page"><header class="tr-page-h"><p class="tr-kicker">Walk</p><h1>Self-guided routes</h1><p class="tr-lede">Ordered stops on a map, generated from Wikivoyage's See lists and marked as generated. Every stop cites the page it came from. Not a guided tour: check opening hours and access before you go.</p></header>
++${routes.length ? `<ol class="tr-route-list tr-route-list-big">${routes.map((rt) => `<li><a href="/routes/${rt.id}"><strong>${rt.title}</strong><span class="tr-muted">${rt.city_name} · ${rt.stop_count} stops · ${(Number(rt.distance_m) / 1000).toFixed(1)} km · ${rt.duration_min} min</span><span class="tr-muted">${rt.summary || ''}</span></a></li>`).join('')}</ol>` : '<p class="tr-muted">No route has been generated yet. The routes job needs a Wikivoyage See list with at least four places that have coordinates.</p>'}
++<p class="tr-fine">Wikivoyage CC BY-SA 4.0 · OpenStreetMap ODbL</p></div>`),
++ });
++ });
++
++ r.get('/routes/:id', async (req, res) => {
++ const route = await store.routeById(String(req.params.id || ''));
++ if (!route) return page(req, res, { index: false, title: 'Not found', body: raw(views.notFound()) }, 404);
++ const trips = signedIn(req) ? await store.tripsForSubject(req.viewer.subject) : [];
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: route.title,
++ description: route.summary || `A self-guided route in ${route.city_name}, generated from Wikivoyage.`,
++ head: `<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/maplibre-gl.css">\n<script src="https://unpkg.com/[email protected]/dist/maplibre-gl.js" defer></script>`,
++ jsonLd: [{
++ '@context': 'https://schema.org', '@type': 'TouristTrip', name: route.title, description: route.summary || undefined,
++ url: `${base()}/routes/${route.id}`, touristType: 'Self-guided',
++ itinerary: {
++ '@type': 'ItemList', numberOfItems: route.stops.length,
++ itemListElement: route.stops.map((s, i) => ({
++ '@type': 'ListItem', position: i + 1,
++ item: { '@type': 'TouristAttraction', name: s.title, description: s.note || undefined, geo: { '@type': 'GeoCoordinates', latitude: s.lat, longitude: s.lon }, sameAs: s.source_url },
++ })),
++ },
++ }],
++ body: raw(views.routePage({ route, trips })),
++ });
++ });
++
++ // ── Live and stays ──────────────────────────────────────
++ r.get('/live', async (req, res) => {
++ const [live, recent] = await Promise.all([store.liveNow({ limit: 50 }), store.recentTours({ limit: 25 })]);
++ return page(req, res, {
++ index: true, cache: 'public, max-age=30', title: 'Live tours now',
++ description: 'IRL tours live on OpenVibe.Live now, and recent IRL recordings, with the city when the public title or tags name one.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Live tours on OpenVibe.Tours', url: `${base()}/live`, description: 'IRL tours live on OpenVibe.Live.', siteUrl: base() })],
++ body: raw(views.livePage({ live, recent })),
++ });
++ });
++
++ r.get('/stays', async (req, res) => {
++ const stays = await store.stays({ limit: 60 });
++ const trips = signedIn(req) ? await store.tripsForSubject(req.viewer.subject) : [];
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Stays',
++ description: 'Active rentals from OpenVibe.Rent for the cities Tours covers. No payment and no booking fee on Tours.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Stays on OpenVibe.Tours', url: `${base()}/stays`, description: 'Rentals from OpenVibe.Rent.', siteUrl: base() })],
++ body: raw(views.staysPage({ stays, trips })),
++ });
++ });
++
++ // ── Flights ──────────────────────────────────────────────
++ r.get('/flights', (req, res) => {
++ const values = {
++ from: typeof req.query.from === 'string' ? req.query.from.slice(0, 80) : '',
++ to: typeof req.query.to === 'string' ? req.query.to.slice(0, 80) : '',
++ depart: typeof req.query.depart === 'string' ? req.query.depart.slice(0, 10) : '',
++ return: typeof req.query.return === 'string' ? req.query.return.slice(0, 10) : '',
++ };
++ let result = null; let error = null;
++ if (Object.values(values).some(Boolean)) {
++ const out = flights.plan(values);
++ if (!out.ok) error = out.message; else result = out;
++ }
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Flights',
++ description: 'No flight API key yet: this form opens the official airline-neutral search with the city pair and dates filled in. No affiliate.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'Flights on OpenVibe.Tours', url: `${base()}/flights`, description: 'An official airline-neutral flight search with the city pair filled in.', siteUrl: base() })],
++ body: raw(views.flightsPage({ values, result, error, providers: flights.providers })),
++ });
++ });
++
++ // ── Trips ────────────────────────────────────────────────
++ r.get('/trips', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, { index: false, cache: null, title: 'Trips', body: raw(views.signIn({ title: 'Your trips', text: 'A trip belongs to a signed-in person. Sign in to see yours or plan one.', next: '/trips' })) }, 401);
++ const trips = await store.tripsForSubject(req.viewer.subject);
++ return page(req, res, { index: false, cache: null, title: 'Your trips', body: raw(views.tripsPage({ trips })) });
++ });
++
++ r.get('/trips/new', async (req, res) => {
++ if (!signedIn(req)) return page(req, res, { index: false, cache: null, title: 'Plan a trip', body: raw(views.signIn({ title: 'Plan a trip', text: 'A trip belongs to a signed-in person.', next: '/trips/new' })) }, 401);
++ const cities = await store.listCities({ limit: 100 });
++ return page(req, res, { index: false, cache: null, title: 'Plan a trip', body: raw(views.tripNew({ cities, values: { city: typeof req.query.city === 'string' ? req.query.city : '' } })) });
++ });
++
++ r.post('/trips', guard, async (req, res) => {
++ const out = await store.createTrip({
++ subject: req.viewer.subject, title: req.body.title, citySlug: req.body.city,
++ startsOn: req.body.starts_on, endsOn: req.body.ends_on, notes: req.body.notes, visibility: 'private',
++ });
++ if (!out.ok) {
++ const cities = await store.listCities({ limit: 100 });
++ return page(req, res, { index: false, cache: null, title: 'Plan a trip', body: raw(views.tripNew({ cities, values: formValues(req.body, ['title', 'city', 'starts_on', 'ends_on', 'notes']), error: out.message })) }, 400);
++ }
++ return res.redirect(303, `/trips/${out.trip.slug}`);
++ });
++
++ r.get('/trips/:slug', async (req, res) => {
++ const subject = signedIn(req) ? req.viewer.subject : null;
++ const out = await store.tripBySlug(String(req.params.slug || ''), { subject });
++ if (!out) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, 404);
++ const cities = out.mine ? await store.listCities({ limit: 100 }) : [];
++ return page(req, res, {
++ index: false, cache: out.mine ? null : 'public, max-age=60', title: out.trip.title,
++ jsonLd: [tripJsonLd(out.trip, out.route)], body: raw(views.tripPage({ ...out, cities })),
++ });
++ });
++
++ r.post('/trips/:slug/share', guard, async (req, res) => {
++ const out = await store.updateTrip({ subject: req.viewer.subject, slug: String(req.params.slug || ''), input: { visibility: req.body.shared === '1' ? 'shared' : 'private' } });
++ if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
++ return res.redirect(303, `/trips/${out.trip.slug}`);
++ });
++
++ r.post('/trips/:slug/edit', guard, async (req, res) => {
++ const out = await store.updateTrip({
++ subject: req.viewer.subject, slug: String(req.params.slug || ''),
++ input: { title: req.body.title, city_slug: req.body.city, starts_on: req.body.starts_on, ends_on: req.body.ends_on, notes: req.body.notes },
++ });
++ if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
++ return res.redirect(303, `/trips/${out.trip.slug}`);
++ });
++
++ r.post('/trips/:slug/places', guard, async (req, res) => {
++ const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
++ const out = await store.addTripPlace({ subject: req.viewer.subject, slug, placeId: req.body.place_id, note: req.body.note });
++ if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
++ return res.redirect(303, `/trips/${slug}`);
++ });
++
++ r.post('/trips/:slug/places/:placeId/remove', guard, async (req, res) => {
++ await store.removeTripPlace({ subject: req.viewer.subject, slug: String(req.params.slug || ''), placeId: String(req.params.placeId || '') });
++ return res.redirect(303, `/trips/${String(req.params.slug || '')}`);
++ });
++
++ r.post('/trips/:slug/route', guard, async (req, res) => {
++ const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
++ const out = await store.setTripRoute({ subject: req.viewer.subject, slug, routeId: req.body.route_id || null });
++ if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
++ return res.redirect(303, `/trips/${slug}`);
++ });
++
++ r.post('/trips/:slug/stays', guard, async (req, res) => {
++ const slug = /^trip-[a-z0-9]{16,32}$/.test(String(req.body.trip || '')) ? String(req.body.trip) : String(req.params.slug || '');
++ const out = await store.addTripStay({ subject: req.viewer.subject, slug, stayId: req.body.stay_id });
++ if (!out.ok) return page(req, res, { index: false, cache: null, title: 'Not found', body: raw(views.notFound()) }, out.status || 404);
++ return res.redirect(303, `/trips/${slug}`);
++ });
++
++ r.post('/trips/:slug/stays/:stayId/remove', guard, async (req, res) => {
++ await store.removeTripStay({ subject: req.viewer.subject, slug: String(req.params.slug || ''), stayId: String(req.params.stayId || '') });
++ return res.redirect(303, `/trips/${String(req.params.slug || '')}`);
++ });
++
++ r.post('/trips/:slug/delete', guard, async (req, res) => {
++ await store.deleteTrip({ subject: req.viewer.subject, slug: String(req.params.slug || '') });
++ return res.redirect(303, '/trips');
++ });
++
++ // ── Developers ───────────────────────────────────────────
++ r.get('/developers', async (req, res) => {
++ const jobs = await store.latestJobRuns();
++ return page(req, res, {
++ index: true, cache: PUBLIC_CACHE, title: 'Developers',
++ description: 'The OpenVibe.Tours API, its OpenAPI document, and the open-data sources and licences behind every city, place and route.',
++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Tours API', url: `${base()}/developers`, description: 'The JSON API and the open-data sources.', siteUrl: base() })],
++ body: raw(views.developersPage({ jobs })),
++ });
++ });
++
++ // ── Discovery ────────────────────────────────────────────
++ r.use(createDiscoveryRoutes(ctx));
++ return r;
++}
++
++module.exports = { createPageRoutes };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..98cc0af
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — req.principal:
++ *
++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
++ * a person: their Network user token as a Bearer, or this site's session cookie (tours_at).
++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
++ * a Network app, agent or service token for audience openvibe.tours. 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.Tours'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.tours, 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, 'tours.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.tours itself.', ctx: req.ov });
++ return next();
++ }
++ return http.sendProblem(res, 403, 'tours.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
++ };
++ }
++
++ return { middleware, resolve, requireCapability: require_ };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..e6e845d
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
++ */
++const express = require('express');
++
++function asyncRouter() {
++ const r = express.Router();
++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..2a68828
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -0,0 +1,26 @@
++'use strict';
++
++/**
++ * Account export and deletion → Tours (ADR-033; openvibe-sdk/account-data).
++ *
++ * A person's own rows are their trips. Saved places, the chosen route and stays are part of the trip and go with it
++ * (ON DELETE CASCADE). Public cities, places, routes, live rows and stays are open data, not a person's, and stay.
++ * No row holds a token.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'tours_trips', subject: 'owner_subject', file: 'trips.json', erase: 'delete' },
++];
++
++function create({ db, log = console } = {}) {
++ return createAccountData({
++ db,
++ service: 'tours',
++ tables: TABLES,
++ note: 'Your trips and everything saved on them are erased. Public cities, places and routes are open data and stay.',
++ log,
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/identity/staff.js b/server/identity/staff.js
nnew file mode 100644
iindex 0000000..5a0eb89
---- /dev/null
++++ b/server/identity/staff.js
@@@ -0,0 +1,22 @@
++'use strict';
++
++/**
++ * Who is staff at OpenVibe.Tours: the Network role claim on the person's session (server/auth/sso.js puts `role`
++ * on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's
++ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.
++ *
++ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.
++ */
++const { staff } = require('openvibe-contracts');
++
++const STAFF_MIN = 'global_mod';
++const STAFF_ROLES = ['global_mod', 'admin', 'owner'];
++
++/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */
++function isStaff(who) {
++ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;
++ if (typeof who.role !== 'string' || !who.role) return false;
++ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }
++}
++
++module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..8ada0de
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * OpenVibe.Tours — process entry. `node server/index.js`
++ * Listens on PORT (5170) behind nginx (deploy/). The source jobs start after listen unless TOURS_DRILL is set.
++ */
++const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
++
++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
++ return gracefulStop({
++ name: 'OpenVibe.Tours', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
++ close: [
++ () => { for (const t of timers) clearInterval(t); },
++ () => ctx.jobs.stop(),
++ () => ctx.keys.client.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.Tours] ${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();
++ if (!config.drill) ctx.jobs.start();
++
++ 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.Tours] failed to start:', err); process.exit(1); });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/jobs/cities.js b/server/jobs/cities.js
nnew file mode 100644
iindex 0000000..a380668
---- /dev/null
++++ b/server/jobs/cities.js
@@@ -0,0 +1,139 @@
++'use strict';
++
++/**
++ * The cities job: the top 100 cities from the catalogue, resolved to Wikidata (coordinates, population, image,
++ * country, the English Wikivoyage sitelink), their image licence from Commons, and their Wikivoyage page (lead
++ * summary plus the See listings routes are generated from). Each row keeps its source URL and fetch time.
++ *
++ * If a city's Wikivoyage page is districted and the city page has too few See listings with coordinates, the job
++ * fetches up to TOURS_WIKIVOYAGE_DISTRICTS district pages linked from it. The stored source is the JSON of those
++ * pages, so the routes job can cite the exact page of every stop.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createWikivoyage } = require('../sources/wikivoyage-client');
++const { createWikidata } = require('../sources/wikidata');
++const { parseCityPage, pageUrl } = require('../sources/wikivoyage');
++const { slugify } = require('../store');
++
++const CATALOG = path.join(__dirname, '..', '..', 'data', 'top-cities.json');
++
++function loadCatalog(limit = 100) {
++ const rows = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
++ return rows.slice(0, Math.max(1, Math.min(rows.length, Number(limit) || rows.length)));
++}
++
++function fileTitle(name) {
++ const s = String(name || '').trim();
++ if (!s) return null;
++ return /^File:/i.test(s) ? s : `File:${s}`;
++}
++
++function createCitiesJob({ config, s, store, fetchImpl, log = console }) {
++ const wikivoyage = createWikivoyage({ config, fetchImpl, pace: s.pace });
++ const wikidata = createWikidata({ config, fetchImpl, pace: s.pace, log });
++
++ async function runOnce({ limit = config.openData.cityLimit } = {}) {
++ const catalog = loadCatalog(limit);
++ const result = { catalog: catalog.length, cities: 0, withSummary: 0, withSee: 0, districts: 0, skipped: [], errors: [] };
++
++ const qidMap = await wikidata.resolveQids(catalog.map((c) => c.wikipedia || c.name));
++ const qids = [...qidMap.values()].map((v) => v.qid);
++ const entities = await wikidata.entities(qids);
++ const countryQids = [...new Set([...entities.values()].map((e) => e.countryQid).filter(Boolean))];
++ const countries = await wikidata.countryInfo(countryQids);
++ const imageFiles = [...new Set([...entities.values()].map((e) => fileTitle(e.image)).filter(Boolean))];
++ const images = await wikidata.imageInfo(imageFiles);
++
++ for (const [i, entry] of catalog.entries()) {
++ const resolved = qidMap.get(entry.wikipedia || entry.name);
++ if (!resolved) { result.skipped.push(entry.name); continue; }
++ const entity = entities.get(resolved.qid);
++ if (!entity || entity.lat == null || entity.lon == null) { result.skipped.push(entry.name); continue; }
++ const country = countries.get(entity.countryQid) || { label: entry.country, code: null };
++ const image = images.get(fileTitle(entity.image));
++ const title = entry.wikivoyage || entity.wikivoyageTitle || entry.name;
++ const name = entity.label || entry.name;
++ const city = {
++ name,
++ country: country.label || entry.country,
++ countryCode: country.code || null,
++ lat: entity.lat,
++ lon: entity.lon,
++ population: entity.population,
++ wikidataId: entity.qid,
++ wikipediaTitle: entry.wikipedia || entry.name,
++ wikivoyageTitle: title,
++ slug: slugify(name),
++ imageUrl: image ? image.url : null,
++ imageCredit: image ? image.credit : null,
++ imageLicense: image ? image.license : null,
++ imageLicenseUrl: image ? image.licenseUrl : null,
++ source: 'wikidata',
++ sourceUrl: `https://www.wikidata.org/wiki/${entity.qid}`,
++ fetchedAt: entity.fetchedAt || s.now(),
++ summary: null,
++ };
++ const cityId = await store.upsertCity(city);
++
++ // Wikivoyage: the page, its summary and its See listings; districts only when needed.
++ let main = null;
++ try {
++ main = await wikivoyage.page(title);
++ if (!main && title !== entry.name) main = await wikivoyage.page(entry.name);
++ if (!main) {
++ const found = await wikivoyage.search(entry.name);
++ if (found) main = await wikivoyage.page(found);
++ }
++ } catch (err) {
++ result.errors.push(`${entry.name}: Wikivoyage ${err && err.message}`);
++ }
++ if (main) {
++ const parsed = parseCityPage(main.wikitext, main.title, { districts: config.openData.districtPages });
++ const pages = [{ title: main.title, wikitext: main.wikitext, sourceUrl: main.sourceUrl, fetchedAt: main.fetchedAt }];
++ let see = parsed.see.map((x) => ({ ...x, pageUrl: main.sourceUrl }));
++ if (see.filter((x) => x.hasCoords).length < 4) {
++ for (const district of parsed.districts.slice(0, config.openData.districtPages)) {
++ try {
++ const sub = await wikivoyage.page(district);
++ if (!sub) continue;
++ const subParsed = parseCityPage(sub.wikitext, sub.title, { districts: 0 });
++ see = see.concat(subParsed.see.map((x) => ({ ...x, pageUrl: sub.sourceUrl })));
++ pages.push({ title: sub.title, wikitext: sub.wikitext, sourceUrl: sub.sourceUrl, fetchedAt: sub.fetchedAt });
++ result.districts += 1;
++ } catch (err) {
++ result.errors.push(`${entry.name}/${district}: ${err && err.message}`);
++ }
++ }
++ }
++ // One listing per name+coordinate, in page order.
++ const seen = new Set();
++ see = see.filter((x) => {
++ const key = `${x.name.toLowerCase()}|${x.lat == null ? '' : x.lat}|${x.lon == null ? '' : x.lon}`;
++ if (seen.has(key)) return false;
++ seen.add(key);
++ return true;
++ });
++ const summary = parsed.summary || null;
++ if (summary) {
++ await store.upsertCity({ ...city, summary, source: 'wikivoyage', sourceUrl: main.sourceUrl, fetchedAt: main.fetchedAt });
++ result.withSummary += 1;
++ }
++ if (see.some((x) => x.hasCoords)) result.withSee += 1;
++ await store.upsertCitySource(cityId, {
++ wikivoyageTitle: main.title,
++ wikitext: JSON.stringify({ pages, see }),
++ sourceUrl: main.sourceUrl,
++ fetchedAt: main.fetchedAt,
++ });
++ }
++ result.cities += 1;
++ if ((i + 1) % 10 === 0) log.log(`[Tours cities] ${i + 1}/${catalog.length} (${result.cities} cities)`);
++ }
++ return result;
++ }
++
++ return { runOnce, loadCatalog, pageUrl };
++}
++
++module.exports = { createCitiesJob, loadCatalog };
ddiff --git a/server/jobs/index.js b/server/jobs/index.js
nnew file mode 100644
iindex 0000000..1b34e9d
---- /dev/null
++++ b/server/jobs/index.js
@@@ -0,0 +1,97 @@
++'use strict';
++
++/**
++ * The source jobs. Each one fills one part of the database from one official/open source, records a run receipt in
++ * tours_job_runs (status, counts, error) and is off under TOURS_DRILL, where tests call runOnce() directly. The
++ * first production run fills the site; later runs refresh on their own interval.
++ */
++const { createPacer } = require('../sources/http');
++const { createCitiesJob } = require('./cities');
++const { createPlacesJob } = require('./places');
++const { createRoutesJob } = require('./routes');
++const { createLiveJob } = require('./live');
++const { createStaysJob } = require('./stays');
++
++function createJobs({ config, s, store, fetchImpl = globalThis.fetch, log = console }) {
++ const pace = createPacer(config.openData.minIntervalMs);
++ s.pace = pace;
++ const ctx = { config, s, store, fetchImpl, log };
++ const creators = {
++ cities: createCitiesJob(ctx),
++ places: createPlacesJob(ctx),
++ routes: createRoutesJob(ctx),
++ live: createLiveJob(ctx),
++ stays: createStaysJob(ctx),
++ };
++
++ const jobs = {
++ cities: { run: creators.cities.runOnce, maxAgeMs: config.jobs.citiesEveryMs, intervalMs: config.jobs.citiesEveryMs },
++ places: { run: creators.places.runOnce, maxAgeMs: config.jobs.placesEveryMs, intervalMs: config.jobs.placesEveryMs },
++ routes: { run: creators.routes.runOnce, maxAgeMs: config.jobs.routesEveryMs, intervalMs: config.jobs.routesEveryMs },
++ live: { run: creators.live.runOnce, maxAgeMs: config.jobs.liveEveryMs, intervalMs: config.jobs.liveEveryMs },
++ stays: { run: creators.stays.runOnce, maxAgeMs: config.jobs.staysEveryMs, intervalMs: config.jobs.staysEveryMs },
++ };
++
++ const running = new Set();
++ const timers = [];
++
++ async function isFresh(name) {
++ const rows = await store.latestJobRuns();
++ const row = rows.find((r) => r.job === name);
++ if (!row || !['ok', 'partial'].includes(row.status) || !row.finished_at) return false;
++ return Date.now() - new Date(row.finished_at).getTime() < jobs[name].maxAgeMs;
++ }
++
++ async function runOnce(name, opts = {}) {
++ const job = jobs[name];
++ if (!job) throw new Error(`unknown job: ${name}`);
++ if (running.has(name)) return { skipped: 'already running' };
++ running.add(name);
++ const id = await store.recordJobStart(name);
++ const started = Date.now();
++ try {
++ const counts = await job.run(opts);
++ const errors = Array.isArray(counts && counts.errors) ? counts.errors : [];
++ const produced = Object.entries(counts || {}).some(([k, v]) => k !== 'errors' && k !== 'catalog' && typeof v === 'number' && v > 0);
++ const status = errors.length ? (produced ? 'partial' : 'failed') : 'ok';
++ await store.recordJobFinish(id, { status, counts, error: errors.length ? errors.slice(0, 10).join('; ') : null });
++ log.log(`[Tours ${name}] ${status} in ${Math.round((Date.now() - started) / 1000)}s ${JSON.stringify(counts)}`);
++ return { status, counts };
++ } catch (err) {
++ await store.recordJobFinish(id, { status: 'failed', counts: {}, error: (err && err.message) || String(err) });
++ log.warn(`[Tours ${name}] failed: ${err && err.message}`);
++ return { status: 'failed', error: (err && err.message) || String(err) };
++ } finally {
++ running.delete(name);
++ }
++ }
++
++ async function runAll() {
++ const out = {};
++ for (const name of ['cities', 'places', 'routes', 'live', 'stays']) out[name] = await runOnce(name);
++ return out;
++ }
++
++ function start() {
++ if (config.drill || !config.jobs.enabled) return;
++ const names = Object.keys(jobs);
++ names.forEach((name, i) => {
++ const kick = setTimeout(async () => {
++ try { if (!await isFresh(name)) await runOnce(name); } catch (err) { log.warn(`[Tours ${name}] ${err && err.message}`); }
++ }, 4000 + i * 750);
++ kick.unref();
++ const timer = setInterval(() => { runOnce(name).catch(() => {}); }, jobs[name].intervalMs);
++ timer.unref();
++ timers.push(timer, kick);
++ });
++ }
++
++ function stop() {
++ for (const t of timers) clearInterval(t);
++ timers.length = 0;
++ }
++
++ return { runOnce, runAll, start, stop, isFresh, jobs };
++}
++
++module.exports = { createJobs };
ddiff --git a/server/jobs/live.js b/server/jobs/live.js
nnew file mode 100644
iindex 0000000..f6c8a67
---- /dev/null
++++ b/server/jobs/live.js
@@@ -0,0 +1,59 @@
++'use strict';
++
++/**
++ * The live job: OpenVibe.Live's public stream list (IRL now) and recent IRL VODs. Live publishes no location, so a
++ * city is attached only when the stream's title or tags plainly name a Tour city; otherwise city_id stays NULL and
++ * the item still appears under "Live tours now". No follow, no chat write, no token.
++ */
++const { createLive } = require('../sources/live');
++
++function cityMatcher(cities) {
++ const names = cities.map((c) => ({ id: c.id, name: c.name, re: new RegExp(`(^|[^\\p{L}])${c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\p{L}]|$)`, 'iu') }))
++ .sort((a, b) => b.name.length - a.name.length);
++ return (text) => names.find((n) => n.re.test(text)) || null;
++}
++
++function createLiveJob({ config, s, store, fetchImpl, log = console }) {
++ const live = createLive({ config, fetchImpl, pace: s.pace });
++
++ async function runOnce() {
++ const cities = await store.listCities({ limit: 200 });
++ const match = cityMatcher(cities);
++ const result = { streams: 0, vods: 0, clips: 0, withCity: 0, errors: [] };
++ let streams = [];
++ let recent = [];
++ let clips = [];
++ try { streams = (await live.streams()).streams; } catch (err) { result.errors.push(`streams: ${err && err.message}`); }
++ try { recent = (await live.recent(50)).streams; } catch (err) { result.errors.push(`recent: ${err && err.message}`); }
++ try { clips = (await live.clips(50)).clips; } catch (err) { result.errors.push(`clips: ${err && err.message}`); }
++
++ // Replace the live list only when the list answered: never empty it because the source was down.
++ if (!result.errors.some((e) => e.startsWith('streams:'))) {
++ await store.clearLiveStreams();
++ for (const item of streams) {
++ const city = match(`${item.title} ${item.tags.join(' ')}`);
++ await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
++ result.streams += 1;
++ if (city) result.withCity += 1;
++ }
++ }
++ for (const item of recent) {
++ const city = match(`${item.title} ${item.tags.join(' ')}`);
++ await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
++ result.vods += 1;
++ if (city) result.withCity += 1;
++ }
++ for (const item of clips) {
++ const city = match(`${item.title} `);
++ await store.upsertLive({ ...item, cityId: city ? city.id : null, cityName: city ? city.name : null });
++ result.clips += 1;
++ if (city) result.withCity += 1;
++ }
++ await store.trimLiveVods(200);
++ return result;
++ }
++
++ return { runOnce };
++}
++
++module.exports = { createLiveJob, cityMatcher };
ddiff --git a/server/jobs/places.js b/server/jobs/places.js
nnew file mode 100644
iindex 0000000..62af842
---- /dev/null
++++ b/server/jobs/places.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++/**
++ * The places job, in two passes:
++ *
++ * 1. Wikivoyage: every city's stored See listings with coordinates become a cited place (CC BY-SA) unless an
++ * OpenStreetMap place of the same name or within 120 m is already stored. This makes every city populated
++ * even when Overpass is queueing.
++ * 2. OpenStreetMap: one bounded `tourism=*` Overpass query for the cities whose OSM data is oldest, limited by
++ * TOURS_OSM_CITIES_PER_RUN so one run never hammers the public instance. ODbL; every place keeps its
++ * openstreetmap.org URL and the fetch time. A failure leaves the previous rows and the Wikivoyage fallback.
++ */
++const { createOverpass } = require('../sources/osm');
++const { parseCityPage } = require('../sources/wikivoyage');
++const { stableId } = require('../store');
++
++const R = 6_371_000;
++function distanceM(a, b) {
++ const rad = (d) => (d * Math.PI) / 180;
++ const dLat = rad(Number(b.lat) - Number(a.lat));
++ const dLon = rad(Number(b.lon) - Number(a.lon));
++ const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(Number(a.lat))) * Math.cos(rad(Number(b.lat))) * Math.sin(dLon / 2) ** 2;
++ return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));
++}
++
++function createPlacesJob({ config, s, store, fetchImpl, log = console }) {
++ const osm = createOverpass({ config, fetchImpl, pace: s.pace });
++
++ async function seedWikivoyage(city) {
++ const source = await store.citySource(city.id);
++ if (!source) return 0;
++ let parsed = null;
++ try { parsed = JSON.parse(source.wikitext); } catch { parsed = null; }
++ let listings = [];
++ if (parsed && Array.isArray(parsed.see)) listings = parsed.see;
++ else if (parsed && Array.isArray(parsed.pages)) {
++ for (const page of parsed.pages) {
++ const p = parseCityPage(page.wikitext || '', page.title || '', { districts: 0 });
++ listings = listings.concat(p.see.map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));
++ }
++ }
++ const existing = await store.cityPlaces(city.id, { limit: 200 });
++ let n = 0;
++ for (const listing of listings) {
++ if (!listing.hasCoords || !Number.isFinite(Number(listing.lat)) || !Number.isFinite(Number(listing.lon))) continue;
++ const name = String(listing.name || '').trim();
++ if (!name) continue;
++ const key = name.toLowerCase();
++ if (existing.some((p) => p.name.toLowerCase() === key || distanceM(p, listing) <= 120)) continue;
++ const place = {
++ cityId: city.id,
++ osmType: 'wikivoyage',
++ osmId: stableId('wv', `${city.id}:${key}:${listing.lat}:${listing.lon}`),
++ name,
++ kind: 'attraction',
++ lat: Number(listing.lat),
++ lon: Number(listing.lon),
++ description: listing.note || null,
++ wikidataId: listing.wikidata || null,
++ source: 'wikivoyage',
++ sourceUrl: listing.pageUrl || source.source_url,
++ fetchedAt: source.fetched_at,
++ };
++ await store.upsertPlace(place);
++ existing.push({ name, lat: place.lat, lon: place.lon, id: null });
++ n += 1;
++ }
++ return n;
++ }
++
++ async function runOnce({ limit = config.openData.cityLimit, osmLimit = config.openData.osmCitiesPerRun } = {}) {
++ const result = { cities: 0, wikivoyagePlaces: 0, places: 0, osmCities: 0, empty: [], errors: [] };
++
++ // 1. Wikivoyage places for every city (fast, cited, no external request).
++ const cities = await store.listCities({ limit: Math.max(1, Math.min(100, Number(limit) || 100)) });
++ for (const city of cities) {
++ try {
++ result.wikivoyagePlaces += await seedWikivoyage(city);
++ result.cities += 1;
++ } catch (err) {
++ result.errors.push(`wikivoyage ${city.slug}: ${err && err.message}`);
++ }
++ }
++
++ // 2. OpenStreetMap for the cities whose OSM data is oldest, bounded per run.
++ const want = Math.min(100, Math.max(0, Number(osmLimit) || 0));
++ if (!want) return result;
++ const osmCities = await store.citiesForOsm(want);
++ for (const [i, city] of osmCities.entries()) {
++ try {
++ const { elements, fetchedAt } = await osm.tourism(city.lat, city.lon, config.openData.radiusM);
++ const seen = new Set();
++ const existing = await store.cityPlaces(city.id, { limit: 200 });
++ let n = 0;
++ for (const el of elements) {
++ if (n >= config.openData.maxPerCity) break;
++ const place = osm.placeFromElement(el, city.id, fetchedAt);
++ if (!place) continue;
++ const key = `${place.osmType}:${place.osmId}`;
++ if (seen.has(key)) continue;
++ seen.add(key);
++ if (existing.some((p) => p.source === 'wikivoyage' && (p.name.toLowerCase() === place.name.toLowerCase() || distanceM(p, place) <= 120))) continue;
++ await store.upsertPlace(place);
++ n += 1;
++ }
++ await store.markCityOsmFetched(city.id);
++ result.places += n;
++ result.osmCities += 1;
++ if (!n) result.empty.push(city.slug);
++ } catch (err) {
++ result.errors.push(`${city.slug}: ${err && err.message}`);
++ log.warn(`[Tours places] ${city.slug}: ${err && err.message}`);
++ }
++ if ((i + 1) % 5 === 0) log.log(`[Tours places] OSM ${i + 1}/${osmCities.length} (${result.places} places)`);
++ }
++ return result;
++ }
++
++ return { runOnce, seedWikivoyage, distanceM };
++}
++
++module.exports = { createPlacesJob, distanceM };
ddiff --git a/server/jobs/routes.js b/server/jobs/routes.js
nnew file mode 100644
iindex 0000000..c23bf89
---- /dev/null
++++ b/server/jobs/routes.js
@@@ -0,0 +1,90 @@
++'use strict';
++
++/**
++ * The routes job: a few self-guided walks per city from Wikivoyage's See lists, marked as generated and cited to
++ * the exact Wikivoyage page of every stop. A route needs at least four stops with coordinates; a city with fewer
++ * gets none rather than an invented one. There is no speech API today, so every stop's audio_status is "none".
++ */
++const { parseCityPage } = require('../sources/wikivoyage');
++const { stableId } = require('../store');
++
++const R = 6_371_000;
++function haversineM(a, b) {
++ if (!a || !b) return 0;
++ const rad = (d) => (d * Math.PI) / 180;
++ const dLat = rad(b.lat - a.lat);
++ const dLon = rad(b.lon - a.lon);
++ const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
++ return Math.round(2 * R * Math.asin(Math.min(1, Math.sqrt(h))));
++}
++
++function routeTitle(city, index, count) {
++ return index === 0 ? `${city.name}: a self-guided walk` : `${city.name}: more of the See list`;
++}
++
++function createRoutesJob({ config, s, store, fetchImpl, log = console }) {
++ async function runOnce({ limit = config.openData.cityLimit } = {}) {
++ const cities = await store.listCities({ limit });
++ const result = { cities: 0, routes: 0, stops: 0, noStops: [], errors: [] };
++ for (const city of cities) {
++ const source = await store.citySource(city.id);
++ if (!source) { result.noStops.push(city.slug); continue; }
++ let parsed = null;
++ try { parsed = JSON.parse(source.wikitext); } catch { parsed = null; }
++ let listings = [];
++ if (parsed && Array.isArray(parsed.see)) listings = parsed.see;
++ else if (parsed && Array.isArray(parsed.pages)) {
++ listings = [];
++ for (const page of parsed.pages) {
++ const p = parseCityPage(page.wikitext || '', page.title || '', { districts: 0 });
++ listings = listings.concat(p.see.map((x) => ({ ...x, pageUrl: page.sourceUrl || source.source_url })));
++ }
++ }
++ const usable = listings.filter((x) => x && x.hasCoords && Number.isFinite(Number(x.lat)) && Number.isFinite(Number(x.lon)));
++ if (usable.length < 4) { result.noStops.push(city.slug); continue; }
++ const places = await store.cityPlaces(city.id, { limit: 200 });
++ const chunks = usable.length >= 8 ? [usable.slice(0, 7), usable.slice(7, 14)] : [usable.slice(0, 8)];
++ const routes = [];
++ for (const [i, stops] of chunks.entries()) {
++ if (stops.length < 4) continue;
++ let distance = 0;
++ for (let n = 1; n < stops.length; n += 1) distance += haversineM(stops[n - 1], stops[n]);
++ const duration = Math.round((distance / 1000 / 4.5) * 60) + stops.length * 5;
++ routes.push({
++ id: stableId('rte', `${city.wikidata_id}:see:${i}`),
++ key: `${city.wikidata_id}:see:${i}`,
++ slug: `${city.slug}-see-${i + 1}`,
++ title: routeTitle(city, i, stops.length),
++ summary: `Generated from Wikivoyage's See list for ${city.name}: ${stops.length} stops, about ${(distance / 1000).toFixed(1)} km and ${duration} minutes on foot. Not a guided tour; check each place before you go.`,
++ sourceUrl: source.source_url,
++ fetchedAt: source.fetched_at,
++ distanceM: distance,
++ durationMin: duration,
++ stops: stops.map((stop) => {
++ const exact = places.find((p) => p.name.toLowerCase() === stop.name.toLowerCase());
++ const near = exact || places.find((p) => haversineM(p, stop) <= 250) || null;
++ return {
++ title: stop.name,
++ note: stop.note || `From the Wikivoyage See list for ${city.name}.`,
++ lat: Number(stop.lat),
++ lon: Number(stop.lon),
++ sourceUrl: stop.pageUrl || source.source_url,
++ placeId: near ? near.id : null,
++ };
++ }),
++ });
++ }
++ if (!routes.length) { result.noStops.push(city.slug); continue; }
++ await store.replaceRoutesForCity(city.id, routes);
++ result.cities += 1;
++ result.routes += routes.length;
++ result.stops += routes.reduce((n, r) => n + r.stops.length, 0);
++ if ((result.cities % 10) === 0) log.log(`[Tours routes] ${result.cities} cities, ${result.routes} routes`);
++ }
++ return result;
++ }
++
++ return { runOnce, haversineM };
++}
++
++module.exports = { createRoutesJob, haversineM, routeTitle };
ddiff --git a/server/jobs/stays.js b/server/jobs/stays.js
nnew file mode 100644
iindex 0000000..e2de6fb
---- /dev/null
++++ b/server/jobs/stays.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * The stays job: OpenVibe.Rent's public listings for a city. Rent is read-only here; a stay is a link to the
++ * listing on openvibe.rent and never a payment. When Rent has no listing at all the job records an honest zero
++ * instead of calling once per city.
++ */
++const { createRent } = require('../sources/rent');
++const { fetchJson } = require('../sources/http');
++
++function createStaysJob({ config, s, store, fetchImpl, log = console }) {
++ const rent = createRent({ config, fetchImpl, pace: s.pace });
++
++ async function anyListing() {
++ const { json } = await fetchJson(`${config.upstreams.rent}/api/v1/listings?limit=1`, {
++ fetchImpl, pace: s.pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'OpenVibe.Rent',
++ });
++ return Boolean(json && Array.isArray(json.listings) && json.listings.length > 0);
++ }
++
++ async function runOnce({ limit = config.openData.cityLimit } = {}) {
++ const result = { cities: 0, stays: 0, empty: true, errors: [] };
++ try {
++ if (!await anyListing()) return result;
++ } catch (err) {
++ result.errors.push(err && err.message);
++ return result;
++ }
++ result.empty = false;
++ const cities = await store.listCities({ limit });
++ for (const city of cities) {
++ try {
++ const { stays } = await rent.listings(city.name, { cityId: city.id, limit: 8 });
++ for (const stay of stays) await store.upsertStay(stay);
++ result.stays += stays.length;
++ if (stays.length) result.cities += 1;
++ } catch (err) {
++ result.errors.push(`${city.slug}: ${err && err.message}`);
++ }
++ }
++ return result;
++ }
++
++ return { runOnce };
++}
++
++module.exports = { createStaysJob };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..66f3e40
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,39 @@
++'use strict';
++
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
++ * the OAuth client and Valkey 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: 'tours',
++ 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') },
++ ],
++ });
++}
++
++module.exports = { createServiceReadiness };
ddiff --git a/server/openapi.js b/server/openapi.js
nnew file mode 100644
iindex 0000000..9700003
---- /dev/null
++++ b/server/openapi.js
@@@ -0,0 +1,162 @@
++'use strict';
++
++/** The OpenAPI 3.1 document served at GET /api/v1/openapi.json. Public reads; trips are a signed-in person's. */
++const problem = {
++ type: 'object',
++ required: ['type', 'title', 'status', 'code'],
++ properties: {
++ type: { type: 'string', format: 'uri' }, title: { type: 'string' }, status: { type: 'integer' },
++ code: { type: 'string' }, detail: { type: 'string' }, request_id: { type: 'string' },
++ },
++};
++const city = {
++ type: 'object',
++ properties: {
++ id: { type: 'string', pattern: '^cty_[0-9A-HJKMNP-TV-Z]{26}$' }, slug: { type: 'string' }, name: { type: 'string' },
++ country: { type: 'string' }, country_code: { type: ['string', 'null'] }, region: { type: ['string', 'null'] },
++ lat: { type: 'number' }, lon: { type: 'number' }, population: { type: ['integer', 'null'] },
++ wikidata_id: { type: 'string' }, wikivoyage_title: { type: ['string', 'null'] }, summary: { type: ['string', 'null'] },
++ image_url: { type: ['string', 'null'] }, image_credit: { type: ['string', 'null'] }, image_license: { type: ['string', 'null'] },
++ image_license_url: { type: ['string', 'null'] }, source: { type: 'string' }, source_url: { type: 'string', format: 'uri' },
++ fetched_at: { type: 'string', format: 'date-time' }, place_count: { type: 'integer' }, route_count: { type: 'integer' },
++ },
++};
++const place = {
++ type: 'object',
++ properties: {
++ id: { type: 'string', pattern: '^plc_[0-9A-HJKMNP-TV-Z]{26}$' }, city_id: { type: 'string' }, city_slug: { type: ['string', 'null'] },
++ city_name: { type: ['string', 'null'] }, osm_type: { type: 'string', enum: ['node', 'way', 'relation'] }, osm_id: { type: 'string' },
++ name: { type: 'string' }, kind: { type: 'string' }, lat: { type: 'number' }, lon: { type: 'number' },
++ address: { type: ['string', 'null'] }, website: { type: ['string', 'null'] }, opening_hours: { type: ['string', 'null'] },
++ wheelchair: { type: ['string', 'null'] }, fee: { type: ['string', 'null'] }, wikidata_id: { type: ['string', 'null'] },
++ image_url: { type: ['string', 'null'] }, image_credit: { type: ['string', 'null'] }, image_license: { type: ['string', 'null'] },
++ description: { type: ['string', 'null'] }, source: { type: 'string' }, source_url: { type: 'string', format: 'uri' },
++ fetched_at: { type: 'string', format: 'date-time' }, osm_url: { type: 'string', format: 'uri' }, map_url: { type: 'string', format: 'uri' },
++ },
++};
++const stop = {
++ type: 'object',
++ properties: {
++ position: { type: 'integer' }, place_id: { type: ['string', 'null'] }, title: { type: 'string' }, note: { type: ['string', 'null'] },
++ lat: { type: 'number' }, lon: { type: 'number' }, source: { type: 'string' }, source_url: { type: 'string', format: 'uri' },
++ audio_status: { type: 'string', enum: ['none', 'ready'] }, audio_url: { type: ['string', 'null'] }, map_url: { type: 'string', format: 'uri' },
++ },
++};
++const route = {
++ type: 'object',
++ properties: {
++ id: { type: 'string', pattern: '^rte_[0-9A-HJKMNP-TV-Z]{26}$' }, city_id: { type: 'string' }, city_slug: { type: ['string', 'null'] },
++ city_name: { type: ['string', 'null'] }, slug: { type: 'string' }, title: { type: 'string' }, summary: { type: ['string', 'null'] },
++ source: { type: 'string' }, source_url: { type: 'string', format: 'uri' }, fetched_at: { type: 'string', format: 'date-time' },
++ distance_m: { type: 'integer' }, duration_min: { type: 'integer' }, stop_count: { type: 'integer' }, generated: { type: 'boolean' },
++ stops: { type: 'array', items: { $ref: '#/components/schemas/Stop' } },
++ },
++};
++const live = {
++ type: 'object',
++ properties: {
++ id: { type: 'string' }, kind: { type: 'string', enum: ['stream', 'vod', 'clip'] }, username: { type: 'string' }, display_name: { type: ['string', 'null'] },
++ title: { type: 'string' }, category: { type: ['string', 'null'] }, thumbnail_url: { type: ['string', 'null'] },
++ viewer_count: { type: 'integer' }, city_id: { type: ['string', 'null'] }, city_name: { type: ['string', 'null'] },
++ url: { type: 'string', format: 'uri' }, started_at: { type: ['string', 'null'] }, ended_at: { type: ['string', 'null'] },
++ fetched_at: { type: 'string', format: 'date-time' },
++ },
++};
++const stay = {
++ type: 'object',
++ properties: {
++ id: { type: 'string' }, external_id: { type: 'string' }, city_id: { type: ['string', 'null'] }, city_name: { type: 'string' },
++ title: { type: 'string' }, kind: { type: 'string' }, price: { type: 'number' }, currency: { type: 'string' },
++ period: { type: 'string' }, neighbourhood: { type: ['string', 'null'] }, url: { type: 'string', format: 'uri' },
++ fetched_at: { type: 'string', format: 'date-time' },
++ },
++};
++const trip = {
++ type: 'object',
++ properties: {
++ id: { type: 'string' }, slug: { type: 'string' }, title: { type: 'string' }, city_id: { type: ['string', 'null'] },
++ city_slug: { type: ['string', 'null'] }, city_name: { type: ['string', 'null'] }, starts_on: { type: 'string', format: 'date' },
++ ends_on: { type: 'string', format: 'date' }, notes: { type: ['string', 'null'] }, visibility: { type: 'string', enum: ['private', 'shared'] },
++ revision: { type: 'integer' }, created_at: { type: 'string', format: 'date-time' }, updated_at: { type: 'string', format: 'date-time' },
++ },
++};
++
++const json = (schema) => ({ '200': { description: 'OK', content: { 'application/json': { schema } } }, default: { description: 'A problem', content: { 'application/problem+json': { schema: { $ref: '#/components/schemas/Problem' } } } } });
++const unauth = { '401': { description: 'Sign in required' }, '403': { description: 'Not yours, or a cross-site session write' }, '404': { description: 'Not found' }, '429': { description: 'Rate limited' } };
++const pathParam = (name, example) => ({ name, in: 'path', required: true, schema: { type: 'string' }, example });
++const query = (name, schema, description) => ({ name, in: 'query', required: false, schema, description });
++
++const document = {
++ openapi: '3.1.0',
++ info: {
++ title: 'OpenVibe.Tours API',
++ version: '0.1.0',
++ description: 'Go anywhere: fly, stay, walk, watch. Cities, places, self-guided routes, IRL tours from OpenVibe.Live, stays from OpenVibe.Rent, and a signed-in person\'s trips. Public data carries its source and fetch time.',
++ license: { name: 'AGPL-3.0-only', url: 'https://www.gnu.org/licenses/agpl-3.0.en.html' },
++ },
++ servers: [{ url: 'https://openvibe.tours', description: 'production' }],
++ paths: {
++ '/api/v1/ping': { get: { summary: 'Liveness', responses: json({ type: 'object', properties: { ok: { type: 'boolean' }, service: { type: 'string' } } }) } },
++ '/api/v1/cities': {
++ get: {
++ summary: 'List the top cities', tags: ['Cities'],
++ parameters: [query('limit', { type: 'integer', minimum: 1, maximum: 200, default: 100 }), query('offset', { type: 'integer', minimum: 0, default: 0 }), query('q', { type: 'string', maxLength: 80 })],
++ responses: json({ type: 'object', properties: { count: { type: 'integer' }, cities: { type: 'array', items: { $ref: '#/components/schemas/City' } } } }),
++ },
++ },
++ '/api/v1/cities/{slug}': {
++ get: {
++ summary: 'One city with its places and routes', tags: ['Cities'], parameters: [pathParam('slug', 'lisbon')],
++ responses: { ...json({ type: 'object', properties: { city: { $ref: '#/components/schemas/City' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } }, live: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such city' } },
++ },
++ },
++ '/api/v1/places/{id}': {
++ get: { summary: 'One place', tags: ['Places'], parameters: [pathParam('id', 'plc_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { ...json({ type: 'object', properties: { place: { $ref: '#/components/schemas/Place' }, routes: { type: 'array', items: { $ref: '#/components/schemas/Route' } } } }), '404': { description: 'No such place' } } },
++ },
++ '/api/v1/routes/{id}': {
++ get: { summary: 'One route with its ordered stops', tags: ['Routes'], parameters: [pathParam('id', 'rte_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { ...json({ type: 'object', properties: { route: { $ref: '#/components/schemas/Route' } } }), '404': { description: 'No such route' } } },
++ },
++ '/api/v1/live': {
++ get: {
++ summary: 'IRL tours now and recent IRL VODs', tags: ['Live'],
++ responses: json({ type: 'object', properties: { live: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, recent: { type: 'array', items: { $ref: '#/components/schemas/Live' } }, source: { type: 'string' }, fetched_at: { type: ['string', 'null'] } } }),
++ },
++ },
++ '/api/v1/openapi.json': { get: { summary: 'This document', responses: { '200': { description: 'OpenAPI 3.1 JSON' } } } },
++ '/api/v1/trips': {
++ get: { summary: 'Your trips', tags: ['Trips'], responses: { ...json({ type: 'object', properties: { trips: { type: 'array', items: { $ref: '#/components/schemas/Trip' } } } }), ...unauth } },
++ post: {
++ summary: 'Create a trip', tags: ['Trips'],
++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['title', 'city_slug', 'starts_on', 'ends_on'], properties: { title: { type: 'string', minLength: 3, maxLength: 120 }, city_slug: { type: 'string' }, starts_on: { type: 'string', format: 'date' }, ends_on: { type: 'string', format: 'date' }, notes: { type: 'string', maxLength: 2000 }, visibility: { type: 'string', enum: ['private', 'shared'] } } } } } },
++ responses: { '201': { description: 'Created', content: { 'application/json': { schema: { $ref: '#/components/schemas/Trip' } } } }, '400': { description: 'Invalid trip' }, ...unauth },
++ },
++ },
++ '/api/v1/trips/{slug}': {
++ get: { summary: 'Your trip, or a shared one read-only', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], responses: { ...json({ type: 'object', properties: { trip: { $ref: '#/components/schemas/Trip' }, mine: { type: 'boolean' }, places: { type: 'array', items: { $ref: '#/components/schemas/Place' } }, route: { oneOf: [{ $ref: '#/components/schemas/Route' }, { type: 'null' }] }, stays: { type: 'array', items: { $ref: '#/components/schemas/Stay' } } } }), '404': { description: 'No such trip, or it is private' } } },
++ patch: { summary: 'Edit your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, city_slug: { type: 'string' }, starts_on: { type: 'string', format: 'date' }, ends_on: { type: 'string', format: 'date' }, notes: { type: 'string' }, visibility: { type: 'string', enum: ['private', 'shared'] } } } } } }, responses: { ...json({ $ref: '#/components/schemas/Trip' }), ...unauth } },
++ delete: { summary: 'Delete your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], responses: { '204': { description: 'Deleted' }, ...unauth } },
++ },
++ '/api/v1/trips/{slug}/places': {
++ post: { summary: 'Save a place to your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['place_id'], properties: { place_id: { type: 'string' }, note: { type: 'string', maxLength: 500 } } } } } }, responses: { '201': { description: 'Saved' }, ...unauth } },
++ },
++ '/api/v1/trips/{slug}/places/{placeId}': {
++ delete: { summary: 'Remove a place from your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567'), pathParam('placeId', 'plc_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { '204': { description: 'Removed' }, ...unauth } },
++ },
++ '/api/v1/trips/{slug}/route': {
++ post: { summary: 'Set or clear the trip route', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { route_id: { type: ['string', 'null'] } } } } } }, responses: { '200': { description: 'Set' }, ...unauth } },
++ },
++ '/api/v1/trips/{slug}/stays': {
++ post: { summary: 'Add a stay to your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567')], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['stay_id'], properties: { stay_id: { type: 'string' } } } } } }, responses: { '201': { description: 'Added' }, ...unauth } },
++ },
++ '/api/v1/trips/{slug}/stays/{stayId}': {
++ delete: { summary: 'Remove a stay from your trip', tags: ['Trips'], parameters: [pathParam('slug', 'trip-0123456789abcdef01234567'), pathParam('stayId', 'sty_01JABCDEFGHJKMNPQRSTVWXYZ0')], responses: { '204': { description: 'Removed' }, ...unauth } },
++ },
++ },
++ components: {
++ schemas: { Problem: problem, City: city, Place: place, Stop: stop, Route: route, Live: live, Stay: stay, Trip: trip },
++ securitySchemes: { session: { type: 'apiKey', in: 'cookie', name: 'tours_at' }, bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } },
++ },
++ tags: [{ name: 'Cities' }, { name: 'Places' }, { name: 'Routes' }, { name: 'Live' }, { name: 'Trips' }],
++};
++
++module.exports = { document };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..2769b87
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,32 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
++const raw = (s) => new Raw(s);
++function html(strings, ...values) {
++ let out = strings[0];
++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
++ return raw(out);
++}
++function render(v) {
++ if (v == null || v === false) return '';
++ if (v instanceof Raw) return v.s;
++ if (Array.isArray(v)) return v.map(render).join('');
++ return esc(v);
++}
++
++const code = (s) => html`<code>${s}</code>`;
++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
++
++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
++ if (!rows.length) return html`<p class="muted">${empty}</p>`;
++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..4fc48b7
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,125 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
++ * This is Fun's layout with Tours' name, navigation, room accent and assets. The route page is the one page that
++ * loads MapLibre (from a CDN) for the map; without JavaScript it still lists every stop and links the OSM map.
++ */
++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.Tours';
++const TAGLINE = 'Go anywhere: fly, stay, walk, watch.';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Home', href: '/' },
++ { label: 'Cities', href: '/cities' },
++ { label: 'Live tours', href: '/live' },
++ { label: 'Routes', href: '/routes' },
++ { label: 'Stays', href: '/stays' },
++ { label: 'Flights', href: '/flights' },
++ { label: 'Trips', href: '/trips' },
++ { 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, scripts, head
++ */
++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: 'tours',
++ apiBase: NETWORK_URL,
++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || (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: 'tours', 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: 'tours' })}
++<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/tours.css')}">
++<script src="${ovServe.url('motion.js')}" defer></script>
++<script src="${asset('js/tours.js')}" defer></script>
++${render(scripts)}
++${o.head || ''}
++<meta name="ov-boost" content="tours@${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="tr ov-room" style="${rooms.style('tours')}">
++${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 */ }
++ try { if (window.OpenVibeRooms) document.documentElement.setAttribute('data-room', 'tours'); } catch (e) { /* optional */ }
++});
++</script>`,
++ });
++}
++
++/** Send a page; a page rendered for a signed-in person is private and never cached. */
++function send(res, status, o) {
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
++ res.set('Vary', 'Cookie');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
ddiff --git a/server/sources/http.js b/server/sources/http.js
nnew file mode 100644
iindex 0000000..6a3d403
---- /dev/null
++++ b/server/sources/http.js
@@@ -0,0 +1,94 @@
++'use strict';
++
++/**
++ * The one outbound HTTP helper the source jobs share: identify every request, pace it per host, keep the answer's
++ * fetch time, and turn a failure into a short, non-secret error. The URLs come from config (the official public
++ * hosts); no user-chosen URL is ever fetched. A user's flight link is navigation only.
++ */
++const { setTimeout: sleep } = require('node:timers/promises');
++
++function hostOf(url) {
++ try { return new URL(url).host; } catch { return String(url); }
++}
++
++/** Serialise requests per host, at least minIntervalMs apart (Overpass/Nominatim policy). */
++function createPacer(minIntervalMs = 0) {
++ const last = new Map();
++ let chain = Promise.resolve();
++ return function pace(url) {
++ const host = hostOf(url);
++ const run = chain.then(async () => {
++ const wait = Math.max(0, (last.get(host) || 0) + minIntervalMs - Date.now());
++ if (wait) await sleep(wait);
++ last.set(host, Date.now());
++ });
++ chain = run.catch(() => {});
++ return run.then(() => () => {});
++ };
++}
++
++class SourceError extends Error {
++ constructor(message, { code = 'tours.source.upstream', status = 502 } = {}) {
++ super(message);
++ this.name = 'SourceError';
++ this.code = code;
++ this.status = status;
++ }
++}
++
++/**
++ * fetchText(url, { fetchImpl, userAgent, headers, timeoutMs, pace, retries })
++ * Returns { text, status, url, fetchedAt }. Retries 429/5xx twice with a short backoff; a 4xx (except 429) is final.
++ */
++async function fetchText(url, opts = {}) {
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const headers = {
++ 'User-Agent': opts.userAgent || 'OpenVibeTours/0.1 (+https://openvibe.tours; [email protected])',
++ Accept: opts.accept || 'application/json, text/plain;q=0.9, */*;q=0.5',
++ ...(opts.headers || {}),
++ };
++ const retries = Number.isFinite(opts.retries) ? opts.retries : 2;
++ let release = null;
++ let lastErr = null;
++ for (let attempt = 0; attempt <= retries; attempt += 1) {
++ if (opts.pace && !release) release = await opts.pace(url);
++ try {
++ const res = await fetchImpl(url, {
++ method: opts.method || 'GET',
++ headers,
++ body: opts.body,
++ signal: AbortSignal.timeout(Math.max(1000, Number(opts.timeoutMs) || 25_000)),
++ redirect: 'follow',
++ });
++ const text = await res.text();
++ if (res.ok) {
++ if (release) { release(); release = null; }
++ return { text, status: res.status, url, fetchedAt: new Date().toISOString() };
++ }
++ lastErr = new SourceError(`${opts.label || hostOf(url)} answered ${res.status}`, { status: res.status === 404 ? 404 : 502 });
++ if (res.status === 429 || res.status >= 500) {
++ if (release) { release(); release = null; }
++ if (attempt < retries) {
++ const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after'));
++ await sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(10_000, retryAfter * 1000) : 800 * (attempt + 1));
++ continue;
++ }
++ }
++ if (release) { release(); release = null; }
++ throw lastErr;
++ } catch (err) {
++ if (release) { release(); release = null; }
++ lastErr = err instanceof SourceError ? err : new SourceError(`${opts.label || hostOf(url)}: ${err && err.name === 'TimeoutError' ? 'timed out' : (err && err.message) || 'request failed'}`);
++ if (attempt < retries) { await sleep(600 * (attempt + 1)); continue; }
++ throw lastErr;
++ }
++ }
++ throw lastErr || new SourceError(`${opts.label || hostOf(url)}: request failed`);
++}
++
++async function fetchJson(url, opts = {}) {
++ const out = await fetchText(url, opts);
++ try { return { ...out, json: JSON.parse(out.text) }; } catch { throw new SourceError(`${opts.label || hostOf(url)} answered something that is not JSON`); }
++}
++
++module.exports = { createPacer, fetchText, fetchJson, SourceError, hostOf };
ddiff --git a/server/sources/live.js b/server/sources/live.js
nnew file mode 100644
iindex 0000000..29af69b
---- /dev/null
++++ b/server/sources/live.js
@@@ -0,0 +1,123 @@
++'use strict';
++
++/**
++ * OpenVibe.Live's public stream list and recent VODs. Live has no location field: a stream is an IRL tour when its
++ * public category is irl/outdoors/travel, its tags say irl, or its title plainly is one (a walk, a tour, a market).
++ * The job then matches a known Tour city by name in the title or tags, which is the only "location" Live publishes.
++ */
++const { fetchJson } = require('./http');
++
++const IRL_CATEGORIES = new Set(['irl', 'outdoors', 'travel', 'walking']);
++const IRL_WORDS = /\b(irl|walk(?:ing)?|tour|city walk|hike|hiking|travel|explore|market|street|neighbou?rhood|sightseeing)\b/i;
++
++function parseTags(value) {
++ if (Array.isArray(value)) return value.map(String);
++ try {
++ const parsed = JSON.parse(String(value || '[]'));
++ return Array.isArray(parsed) ? parsed.map(String) : [];
++ } catch { return String(value || '').split(',').map((s) => s.trim()).filter(Boolean); }
++}
++
++function utc(value) {
++ if (!value) return null;
++ const s = String(value).trim();
++ const d = new Date(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(s) ? `${s.replace(' ', 'T')}Z` : s);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++}
++
++function categoryOf(s) {
++ return String(s.category || s.chosen_category || s.ai_category || '').toLowerCase();
++}
++
++function isIrl(s) {
++ if (IRL_CATEGORIES.has(categoryOf(s))) return true;
++ const tags = parseTags(s.tags).map((t) => t.toLowerCase());
++ if (tags.includes('irl') || tags.includes('travel')) return true;
++ return IRL_WORDS.test(String(s.title || ''));
++}
++
++function streamOf(s, { fetchedAt, liveUrl, videoBase }) {
++ const username = String(s.username || '').slice(0, 64);
++ if (!username) return null;
++ const category = categoryOf(s) || 'irl';
++ return {
++ kind: 'stream',
++ externalId: String(s.id),
++ username,
++ displayName: s.display_name || username,
++ title: String(s.title || `${username} is live`).slice(0, 240),
++ category,
++ thumbnailUrl: /^https:\/\//i.test(s.thumbnail_url || '') ? s.thumbnail_url : null,
++ viewerCount: Math.max(0, Number(s.viewer_count || s.total_viewer_count || 0) || 0),
++ url: `${liveUrl}/@${encodeURIComponent(username)}`,
++ startedAt: utc(s.started_at),
++ endedAt: utc(s.ended_at),
++ fetchedAt,
++ tags: parseTags(s.tags),
++ videoBase,
++ };
++}
++
++function vodOf(s, { fetchedAt, liveUrl, videoBase }) {
++ const base = streamOf(s, { fetchedAt, liveUrl, videoBase });
++ if (!base) return null;
++ const vodId = Number(s.vod_id);
++ return {
++ ...base,
++ kind: 'vod',
++ externalId: `vod-${vodId || s.id}`,
++ url: Number.isSafeInteger(vodId) && vodId > 0 ? `${videoBase}/v/${vodId}` : base.url,
++ thumbnailUrl: /^https:\/\//i.test(s.vod_thumbnail_url || '') ? s.vod_thumbnail_url : base.thumbnailUrl,
++ endedAt: base.endedAt || base.startedAt,
++ };
++}
++
++function clipOf(c, { fetchedAt, videoBase }) {
++ const username = String(c.username || c.streamer_username || '').slice(0, 64);
++ const id = Number(c.id);
++ if (!username || !Number.isSafeInteger(id) || id <= 0) return null;
++ return {
++ kind: 'clip',
++ externalId: `clip-${id}`,
++ username,
++ displayName: c.display_name || c.streamer_display_name || username,
++ title: String(c.title || 'A clip').slice(0, 240),
++ category: 'clip',
++ thumbnailUrl: /^https:\/\//i.test(c.thumbnail_url || '') ? c.thumbnail_url : null,
++ viewerCount: Math.max(0, Number(c.view_count || 0) || 0),
++ url: `${videoBase}/c/${id}`,
++ startedAt: utc(c.created_at),
++ endedAt: utc(c.created_at),
++ fetchedAt,
++ tags: [],
++ };
++}
++
++function createLive({ config, fetchImpl, pace }) {
++ const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'OpenVibe.Live' };
++ const liveUrl = config.upstreams.livePublic || 'https://openvibe.live';
++ const videoBase = config.upstreams.video || 'https://openvibe.video';
++
++ async function streams() {
++ const { json, fetchedAt } = await fetchJson(`${config.upstreams.live}/api/streams`, opts);
++ const list = (json && json.streams) || [];
++ return { streams: list.map((s) => streamOf(s, { fetchedAt, liveUrl, videoBase })).filter((s) => s && isIrl(s)), fetchedAt };
++ }
++
++ async function recent(limit = 50) {
++ const { json, fetchedAt } = await fetchJson(`${config.upstreams.live}/api/streams/recent?limit=${Math.min(100, Math.max(1, Number(limit) || 50))}`, opts);
++ const list = (json && json.streams) || [];
++ return { streams: list.map((s) => vodOf(s, { fetchedAt, liveUrl, videoBase })).filter((s) => s && isIrl(s)), fetchedAt };
++ }
++
++ /** Recent public clips; the IRL test is the title (a clip has no category of its own). */
++ async function clips(limit = 50) {
++ const { json, fetchedAt } = await fetchJson(`${config.upstreams.live}/api/clips?limit=${Math.min(100, Math.max(1, Number(limit) || 50))}`, opts);
++ const list = (json && json.clips) || [];
++ return { clips: list.map((c) => clipOf(c, { fetchedAt, videoBase })).filter((c) => c && isIrl(c)), fetchedAt };
++ }
++
++ return { streams, recent, clips, isIrl, parseTags, categoryOf };
++}
++
++module.exports = { createLive, isIrl, streamOf, vodOf, clipOf, parseTags, utc, IRL_CATEGORIES };
ddiff --git a/server/sources/osm.js b/server/sources/osm.js
nnew file mode 100644
iindex 0000000..79606e3
---- /dev/null
++++ b/server/sources/osm.js
@@@ -0,0 +1,79 @@
++'use strict';
++
++/**
++ * OpenStreetMap through Overpass: one bounded `around:` query per city, tourism=* only, `out center tags`.
++ * ODbL; every place keeps its openstreetmap.org URL and the query's fetch time. No bulk download.
++ */
++const { fetchText } = require('./http');
++
++// What a traveller means by "place". Lodging is Rent's; a hotel is not an attraction.
++const INTERESTING = new Set(['attraction', 'museum', 'viewpoint', 'artwork', 'gallery', 'zoo', 'theme_park', 'aquarium', 'information', 'picnic_site', 'botanical_garden']);
++
++function overpassQuery(lat, lon, radiusM) {
++ const r = Math.max(100, Math.min(10_000, Math.round(Number(radiusM) || 3500)));
++ return `[out:json][timeout:25];nwr["tourism"](around:${r},${Number(lat)},${Number(lon)});out center tags 160;`;
++}
++
++function commonsImage(tag) {
++ const value = String(tag || '').trim();
++ if (!value) return null;
++ if (/^https:\/\//i.test(value)) return value;
++ const file = value.replace(/^File:/i, '').replace(/^Category:/i, '').trim();
++ if (!file || /^Category:/i.test(value)) return null;
++ return `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(file)}?width=1000`;
++}
++
++function addressOf(tags) {
++ const street = [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ');
++ return tags['addr:full'] || [street, tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(', ') || null;
++}
++
++function placeFromElement(el, cityId, fetchedAt) {
++ const tags = el.tags || {};
++ const kind = String(tags.tourism || '').toLowerCase();
++ if (!INTERESTING.has(kind) || !tags.name) return null;
++ const lat = Number.isFinite(Number(el.lat)) ? Number(el.lat) : Number(el.center && el.center.lat);
++ const lon = Number.isFinite(Number(el.lon)) ? Number(el.lon) : Number(el.center && el.center.lon);
++ if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
++ const osmType = el.type === 'node' ? 'node' : el.type === 'way' ? 'way' : 'relation';
++ return {
++ cityId,
++ osmType,
++ osmId: String(el.id),
++ name: String(tags.name).slice(0, 200),
++ kind,
++ lat,
++ lon,
++ address: addressOf(tags),
++ website: /^https?:\/\//i.test(tags.website || '') ? tags.website : null,
++ openingHours: tags.opening_hours || null,
++ wheelchair: ['yes', 'no', 'limited'].includes(tags.wheelchair) ? tags.wheelchair : null,
++ fee: ['yes', 'no'].includes(tags.fee) ? tags.fee : null,
++ wikidataId: /^Q[1-9][0-9]{0,11}$/.test(tags.wikidata || '') ? tags.wikidata : null,
++ wikipedia: tags.wikipedia || null,
++ imageUrl: commonsImage(tags.image) || commonsImage(tags.wikimedia_commons),
++ imageCredit: 'OpenStreetMap contributor',
++ imageLicense: null,
++ description: tags.description ? String(tags.description).slice(0, 800) : null,
++ source: 'openstreetmap',
++ sourceUrl: `https://www.openstreetmap.org/${osmType}/${el.id}`,
++ fetchedAt,
++ };
++}
++
++function createOverpass({ config, fetchImpl, pace }) {
++ async function tourism(lat, lon, radiusM = config.openData.radiusM) {
++ const body = new URLSearchParams({ data: overpassQuery(lat, lon, radiusM) }).toString();
++ const out = await fetchText(config.upstreams.overpass, {
++ fetchImpl, pace, method: 'POST', timeoutMs: config.upstreams.timeoutMs,
++ userAgent: config.openData.userAgent, label: 'Overpass',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body,
++ });
++ let json;
++ try { json = JSON.parse(out.text); } catch { throw new Error('Overpass answered something that is not JSON'); }
++ return { elements: (json && json.elements) || [], fetchedAt: out.fetchedAt };
++ }
++ return { tourism, placeFromElement, overpassQuery, INTERESTING };
++}
++
++module.exports = { createOverpass, placeFromElement, overpassQuery, commonsImage, INTERESTING };
ddiff --git a/server/sources/rent.js b/server/sources/rent.js
nnew file mode 100644
iindex 0000000..ba45c50
---- /dev/null
++++ b/server/sources/rent.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * OpenVibe.Rent's public listings: `GET /api/v1/listings?city=…&limit=…`. Tours reads only; a stay links to the
++ * listing on openvibe.rent and never handles a payment. A city with no listing simply has no stays.
++ */
++const { fetchJson } = require('./http');
++
++function stayOf(row, { fetchedAt, cityId, cityName, rentUrl }) {
++ if (!row || !/^rnt_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(row.id || ''))) return null;
++ if (!['apartment', 'room', 'house', 'commercial', 'parking', 'equipment', 'other'].includes(row.kind)) return null;
++ if (!['month', 'week', 'day', 'hour', 'once'].includes(row.period)) return null;
++ if (!/^[A-Z]{3}$/.test(String(row.currency || ''))) return null;
++ const price = Number(row.price);
++ if (!Number.isFinite(price) || price < 0) return null;
++ return {
++ externalId: row.id,
++ cityId: cityId || null,
++ cityName: cityName || row.city || 'Unknown',
++ title: String(row.title || 'A place to stay').slice(0, 200),
++ kind: row.kind,
++ price,
++ currency: row.currency,
++ period: row.period,
++ neighbourhood: row.neighbourhood || null,
++ url: `${rentUrl}/listings/${row.id}`,
++ fetchedAt,
++ };
++}
++
++function createRent({ config, fetchImpl, pace }) {
++ const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'OpenVibe.Rent' };
++
++ /** The city's active listings. Rent's own filter is the authority; we never widen it to another city. */
++ async function listings(cityName, { cityId = null, limit = 12 } = {}) {
++ const city = String(cityName || '').trim();
++ if (!city) return { stays: [], fetchedAt: null };
++ const url = `${config.upstreams.rent}/api/v1/listings?${new URLSearchParams({ city, limit: String(Math.min(60, Math.max(1, limit))) })}`;
++ const { json, fetchedAt } = await fetchJson(url, opts);
++ const rows = (json && json.listings) || [];
++ return { stays: rows.map((r) => stayOf(r, { fetchedAt, cityId, cityName: city, rentUrl: config.upstreams.rentPublic || 'https://openvibe.rent' })).filter(Boolean), fetchedAt };
++ }
++
++ return { listings, stayOf };
++}
++
++module.exports = { createRent, stayOf };
ddiff --git a/server/sources/wikidata.js b/server/sources/wikidata.js
nnew file mode 100644
iindex 0000000..15a4840
---- /dev/null
++++ b/server/sources/wikidata.js
@@@ -0,0 +1,150 @@
++'use strict';
++
++/**
++ * Wikidata, Wikipedia and Wikimedia Commons (CC0 / CC BY-SA; each image keeps its own licence).
++ *
++ * resolveQids(titles) Wikipedia page title → Wikidata Q id, through pageprops (redirects followed)
++ * entities(qids) coordinates (P625), population (P1082), image (P18), country (P17), the English
++ * Wikivoyage sitelink and the English label
++ * countryInfo(qids) a country's label and ISO 3166-1 alpha-2 (P297)
++ * imageInfo(files) a Commons file's 1200px URL and its licence/credit (extmetadata)
++ */
++const { fetchJson, SourceError } = require('./http');
++
++const chunk = (list, n) => { const out = []; for (let i = 0; i < list.length; i += n) out.push(list.slice(i, i + n)); return out; };
++
++function claimValues(entity, prop) {
++ const claims = entity && entity.claims && entity.claims[prop];
++ if (!Array.isArray(claims)) return [];
++ return claims.map((c) => c && c.mainsnak && c.mainsnak.datavalue && c.mainsnak.datavalue.value).filter(Boolean);
++}
++
++function firstClaim(entity, prop) {
++ return claimValues(entity, prop)[0] || null;
++}
++
++function parseEntity(entity) {
++ if (!entity || !entity.id) return null;
++ const coord = firstClaim(entity, 'P625');
++ const pop = firstClaim(entity, 'P1082');
++ const image = firstClaim(entity, 'P18');
++ const country = firstClaim(entity, 'P17');
++ const label = (entity.labels && entity.labels.en && entity.labels.en.value) || null;
++ const sitelink = entity.sitelinks && entity.sitelinks.enwikivoyage && entity.sitelinks.enwikivoyage.title;
++ return {
++ qid: entity.id,
++ label,
++ lat: coord && Number.isFinite(Number(coord.latitude)) ? Number(coord.latitude) : null,
++ lon: coord && Number.isFinite(Number(coord.longitude)) ? Number(coord.longitude) : null,
++ population: pop && Number.isFinite(Number(String(pop.amount || '').replace(/^\+/, ''))) ? Number(String(pop.amount).replace(/^\+/, '')) : null,
++ image: image ? String(image) : null,
++ countryQid: country && typeof country === 'object' && country.id ? country.id : null,
++ wikivoyageTitle: sitelink || null,
++ };
++}
++
++function createWikidata({ config, fetchImpl, pace, log = console }) {
++ const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'Wikidata' };
++
++ /** Wikipedia titles → Q ids. Returns a Map keyed by the requested title (redirects resolved). */
++ async function resolveQids(titles) {
++ const wanted = [...new Set((titles || []).map((t) => String(t || '').trim()).filter(Boolean))];
++ const out = new Map();
++ for (const group of chunk(wanted, 40)) {
++ const url = `${config.upstreams.wikipedia}/w/api.php?${new URLSearchParams({
++ action: 'query', format: 'json', formatversion: '2', redirects: '1',
++ prop: 'pageprops', ppprop: 'wikibase_item', titles: group.join('|'),
++ })}`;
++ const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikipedia' });
++ const pages = (json && json.query && json.query.pages) || [];
++ const redirects = (json && json.query && json.query.redirects) || [];
++ const redirectTo = new Map(redirects.map((r) => [String(r.from), String(r.to)]));
++ const byTitle = new Map(pages.map((p) => [String(p.title), p]));
++ for (const title of group) {
++ const landed = redirectTo.get(title) || title;
++ const page = byTitle.get(landed) || byTitle.get(title);
++ const qid = page && page.pageprops && page.pageprops.wikibase_item;
++ if (qid && /^Q[1-9][0-9]{0,11}$/.test(qid)) out.set(title, { qid, title: page.title, fetchedAt });
++ }
++ }
++ return out;
++ }
++
++ /** Q ids → parsed entities (all props the city job needs). */
++ async function entities(qids) {
++ const wanted = [...new Set((qids || []).filter((q) => /^Q[1-9][0-9]{0,11}$/.test(String(q))))];
++ const out = new Map();
++ for (const group of chunk(wanted, 40)) {
++ const url = `${config.upstreams.wikidata}/w/api.php?${new URLSearchParams({
++ action: 'wbgetentities', format: 'json', ids: group.join('|'), props: 'claims|labels|sitelinks',
++ languages: 'en', sitefilter: 'enwikivoyage',
++ })}`;
++ const { json, fetchedAt } = await fetchJson(url, { ...opts, label: 'Wikidata' });
++ const entitiesObj = (json && json.entities) || {};
++ for (const qid of group) {
++ const parsed = parseEntity(entitiesObj[qid]);
++ if (parsed) out.set(qid, { ...parsed, fetchedAt });
++ }
++ }
++ return out;
++ }
++
++ /** Country Q ids → { label, code } (ISO 3166-1 alpha-2 from P297). */
++ async function countryInfo(qids) {
++ const wanted = [...new Set((qids || []).filter(Boolean))];
++ const out = new Map();
++ for (const group of chunk(wanted, 40)) {
++ const url = `${config.upstreams.wikidata}/w/api.php?${new URLSearchParams({
++ action: 'wbgetentities', format: 'json', ids: group.join('|'), props: 'claims|labels', languages: 'en',
++ })}`;
++ const { json } = await fetchJson(url, { ...opts, label: 'Wikidata' });
++ const entitiesObj = (json && json.entities) || {};
++ for (const qid of group) {
++ const e = entitiesObj[qid];
++ if (!e) continue;
++ const code = firstClaim(e, 'P297');
++ out.set(qid, {
++ label: (e.labels && e.labels.en && e.labels.en.value) || null,
++ code: typeof code === 'string' ? code : (code && code.id) || null,
++ });
++ }
++ }
++ return out;
++ }
++
++ /** Commons file titles (`File:X.jpg`) → { url, credit, license, licenseUrl }. */
++ async function imageInfo(files) {
++ const wanted = [...new Set((files || []).filter(Boolean))];
++ const out = new Map();
++ for (const group of chunk(wanted, 40)) {
++ const url = `${config.upstreams.commons}/w/api.php?${new URLSearchParams({
++ action: 'query', format: 'json', formatversion: '2', prop: 'imageinfo',
++ iiprop: 'extmetadata|url', iiurlwidth: '1200', titles: group.join('|'),
++ })}`;
++ let json;
++ try { ({ json } = await fetchJson(url, { ...opts, label: 'Commons' })); } catch (err) {
++ log.warn(`[OpenVibe.Tours] Commons image metadata: ${err && err.message}`);
++ continue;
++ }
++ const pages = (json && json.query && json.query.pages) || [];
++ for (const page of pages) {
++ const info = page.imageinfo && page.imageinfo[0];
++ if (!info) continue;
++ const meta = info.extmetadata || {};
++ const val = (k) => (meta[k] && meta[k].value ? String(meta[k].value) : null);
++ const strip = (s) => (s ? s.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() : null);
++ out.set(String(page.title), {
++ url: info.thumburl || info.url || null,
++ credit: strip(val('Artist')) || strip(val('Credit')) || 'Wikimedia Commons',
++ license: strip(val('LicenseShortName')),
++ licenseUrl: val('LicenseUrl'),
++ });
++ }
++ }
++ return out;
++ }
++
++ return { resolveQids, entities, countryInfo, imageInfo, parseEntity };
++}
++
++module.exports = { createWikidata, parseEntity, claimValues, firstClaim, chunk, SourceError };
ddiff --git a/server/sources/wikivoyage-client.js b/server/sources/wikivoyage-client.js
nnew file mode 100644
iindex 0000000..ebb7e0c
---- /dev/null
++++ b/server/sources/wikivoyage-client.js
@@@ -0,0 +1,40 @@
++'use strict';
++
++/** Wikivoyage's MediaWiki API: one page's wikitext, or null when the page does not exist. */
++const { fetchJson } = require('./http');
++const { pageUrl } = require('./wikivoyage');
++
++function createWikivoyage({ config, fetchImpl, pace }) {
++ const opts = { fetchImpl, pace, timeoutMs: config.upstreams.timeoutMs, userAgent: config.openData.userAgent, label: 'Wikivoyage' };
++
++ async function page(title) {
++ const name = String(title || '').trim();
++ if (!name) return null;
++ const url = `${config.upstreams.wikivoyage}/w/api.php?${new URLSearchParams({
++ action: 'parse', page: name, prop: 'wikitext', format: 'json', formatversion: '2', redirects: '1',
++ })}`;
++ const { json, fetchedAt } = await fetchJson(url, opts);
++ if (!json || !json.parse || typeof json.parse.wikitext !== 'string') return null;
++ return {
++ title: json.parse.title || name,
++ wikitext: json.parse.wikitext,
++ revision: json.parse.revid || null,
++ sourceUrl: pageUrl(json.parse.title || name),
++ fetchedAt,
++ };
++ }
++
++ /** Search for a page when the catalogue's title is not the Wikivoyage one. */
++ async function search(title) {
++ const url = `${config.upstreams.wikivoyage}/w/api.php?${new URLSearchParams({
++ action: 'query', list: 'search', srsearch: String(title || ''), srlimit: '1', format: 'json', formatversion: '2',
++ })}`;
++ const { json } = await fetchJson(url, { ...opts, label: 'Wikivoyage search' });
++ const hit = json && json.query && json.query.search && json.query.search[0];
++ return hit ? hit.title : null;
++ }
++
++ return { page, search };
++}
++
++module.exports = { createWikivoyage };
ddiff --git a/server/sources/wikivoyage.js b/server/sources/wikivoyage.js
nnew file mode 100644
iindex 0000000..da66297
---- /dev/null
++++ b/server/sources/wikivoyage.js
@@@ -0,0 +1,204 @@
++'use strict';
++
++/**
++ * Wikivoyage parsing (CC BY-SA). A city page gives the lead summary and its "See" listings; a districted city keeps
++ * the listings on district pages, so the city page's See section links the ones to fetch. Every route stop built
++ * from these keeps the Wikivoyage page URL it came from.
++ *
++ * This module is pure: no network, no database. The job decides what to fetch.
++ */
++
++/** The outside of a balanced {{…}} template, or a single balanced template's inside at index i. */
++function matchingBrace(text, start) {
++ let depth = 0;
++ for (let i = start; i < text.length - 1; i += 1) {
++ if (text[i] === '{' && text[i + 1] === '{') { depth += 1; i += 1; continue; }
++ if (text[i] === '}' && text[i + 1] === '}') {
++ depth -= 1;
++ i += 1;
++ if (depth === 0) return i + 1;
++ }
++ }
++ return -1;
++}
++
++function removeTemplates(text) {
++ let out = '';
++ for (let i = 0; i < text.length; i += 1) {
++ if (text[i] === '{' && text[i + 1] === '{') {
++ const end = matchingBrace(text, i);
++ if (end > 0) { i = end - 1; continue; }
++ }
++ out += text[i];
++ }
++ return out;
++}
++
++function removeFiles(text) {
++ let out = '';
++ for (let i = 0; i < text.length; i += 1) {
++ if (text[i] === '[' && text[i + 1] === '[') {
++ const close = text.indexOf(']]', i + 2);
++ if (close > 0) {
++ const inner = text.slice(i + 2, close);
++ if (/^(file|image)\s*:/i.test(inner)) { i = close + 1; continue; }
++ }
++ }
++ out += text[i];
++ }
++ return out;
++}
++
++const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': "'", '&nbsp;': ' ', '&ndash;': '–', '&mdash;': '—' };
++
++/** Wikitext → plain text: templates and files out, links to their label, markup off. */
++function stripWiki(value, { max = 1200 } = {}) {
++ let s = String(value == null ? '' : value);
++ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
++ s = s.replace(/<ref[^>]*\/>/gi, ' ').replace(/<ref[^>]*>[\s\S]*?<\/ref>/gi, ' ');
++ s = removeTemplates(s);
++ s = removeFiles(s);
++ s = s.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, '$2').replace(/\[\[([^\]]+)\]\]/g, '$1');
++ s = s.replace(/\[https?:\/\/[^\s\]]+\s+([^\]]+)\]/g, '$1').replace(/\[https?:\/\/[^\s\]]+\]/g, ' ');
++ s = s.replace(/'''?/g, '');
++ s = s.replace(/<\/?[a-z][^>]*>/gi, ' ');
++ s = s.replace(/\{\{|\}\}/g, ' ');
++ for (const [k, v] of Object.entries(ENTITIES)) s = s.split(k).join(v);
++ s = s.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, ' ').trim();
++ return s.slice(0, max);
++}
++
++/** The lead: the first real paragraph after the pagebanner/templates, at most 1000 characters. */
++function leadSummary(wikitext) {
++ const text = String(wikitext || '');
++ const clean = removeFiles(removeTemplates(text.replace(/<!--[\s\S]*?-->/g, ' ')));
++ const blocks = clean.split(/\n\s*\n/);
++ for (const block of blocks) {
++ const line = block.trim();
++ if (!line || line.startsWith('|') || line.startsWith('=') || line.startsWith('*') || line.startsWith('#')) continue;
++ const plain = stripWiki(line, { max: 1400 });
++ if (plain.length >= 80) return plain.slice(0, 1000);
++ }
++ return '';
++}
++
++/** Split a template body on top-level pipes (nested [[…]] and {{…}} stay whole). */
++function splitParams(body) {
++ const parts = [];
++ let depthBrace = 0;
++ let depthLink = 0;
++ let cur = '';
++ for (let i = 0; i < body.length; i += 1) {
++ const two = body.slice(i, i + 2);
++ if (two === '{{') { depthBrace += 1; cur += two; i += 1; continue; }
++ if (two === '}}') { depthBrace = Math.max(0, depthBrace - 1); cur += two; i += 1; continue; }
++ if (two === '[[') { depthLink += 1; cur += two; i += 1; continue; }
++ if (two === ']]') { depthLink = Math.max(0, depthLink - 1); cur += two; i += 1; continue; }
++ if (body[i] === '|' && depthBrace === 0 && depthLink === 0) { parts.push(cur); cur = ''; continue; }
++ cur += body[i];
++ }
++ parts.push(cur);
++ return parts;
++}
++
++function paramsOf(body) {
++ const out = {};
++ for (const part of splitParams(body)) {
++ const eq = part.indexOf('=');
++ if (eq < 0) continue;
++ const key = part.slice(0, eq).trim().toLowerCase();
++ if (!key || /[\s]/.test(key)) continue;
++ out[key] = part.slice(eq + 1).trim();
++ }
++ return out;
++}
++
++function coordFrom(params) {
++ for (const [latKey, lonKey] of [['lat', 'long'], ['lat', 'lon'], ['latitude', 'longitude']]) {
++ const lat = Number(params[latKey]);
++ const lon = Number(params[lonKey]);
++ if (Number.isFinite(lat) && Number.isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
++ }
++ const c = String(params.coordinates || params.coord || '');
++ const m = c.match(/(-?\d{1,3}(?:\.\d+)?)\s*[,;|]\s*(-?\d{1,3}(?:\.\d+)?)/);
++ if (m) {
++ const lat = Number(m[1]); const lon = Number(m[2]);
++ if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
++ }
++ return null;
++}
++
++/** Every {{see}} (and old {{listing|type=see}}) on a page, with coordinates when the listing has them. */
++function seeListings(wikitext) {
++ const text = String(wikitext || '');
++ const out = [];
++ const titles = new Set();
++ for (let i = 0; i < text.length - 1; i += 1) {
++ if (!(text[i] === '{' && text[i + 1] === '{')) continue;
++ const end = matchingBrace(text, i);
++ if (end < 0) break;
++ const body = text.slice(i + 2, end - 2);
++ const head = (body.split('|')[0] || '').trim().toLowerCase().replace(/^subst:/, '');
++ const isSee = head === 'see';
++ const isListing = head === 'listing' && /(?:^|\|)\s*type\s*=\s*see\b/i.test(body);
++ if (!isSee && !isListing) { i = end - 1; continue; }
++ const p = paramsOf(body);
++ const name = stripWiki(p.name || p.title || '', { max: 200 });
++ const coord = coordFrom(p);
++ const key = `${name.toLowerCase()}|${coord ? `${coord.lat},${coord.lon}` : 'nocoord'}`;
++ if (name && !titles.has(key)) {
++ titles.add(key);
++ out.push({
++ name,
++ lat: coord ? coord.lat : null,
++ lon: coord ? coord.lon : null,
++ note: stripWiki(p.content || p.description || p.address || '', { max: 600 }),
++ wikidata: String(p.wikidata || '').trim() || null,
++ url: String(p.url || '').trim() || null,
++ hasCoords: Boolean(coord),
++ });
++ }
++ i = end - 1;
++ }
++ return out;
++}
++
++/** District or subpage links on a city page (`[[Lisbon/Alfama|Alfama]]`), in page order. */
++function districtLinks(wikitext, cityTitle, limit = 2) {
++ const text = String(wikitext || '');
++ const title = String(cityTitle || '');
++ const out = [];
++ const seen = new Set();
++ const re = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]/g;
++ let m;
++ while ((m = re.exec(text))) {
++ const target = m[1].trim();
++ if (!target.includes('/')) continue;
++ const prefix = target.split('/')[0];
++ if (title && prefix.toLowerCase() !== title.toLowerCase()) continue;
++ if (seen.has(target)) continue;
++ seen.add(target);
++ out.push(target);
++ if (out.length >= Math.max(0, limit)) break;
++ }
++ return out;
++}
++
++/**
++ * A city page: { summary, see, districts }.
++ * `see` is every listing on the page; the job adds district pages only when there are not enough with coordinates.
++ */
++function parseCityPage(wikitext, title, { districts = 2 } = {}) {
++ return {
++ summary: leadSummary(wikitext),
++ see: seeListings(wikitext),
++ districts: districtLinks(wikitext, title, districts),
++ };
++}
++
++/** The page URL a citation goes to (a district page is a real Wikivoyage page too). */
++function pageUrl(title) {
++ return `https://en.wikivoyage.org/wiki/${encodeURIComponent(String(title).replace(/ /g, '_')).replace(/%2F/g, '/')}`;
++}
++
++module.exports = { stripWiki, leadSummary, seeListings, districtLinks, parseCityPage, pageUrl, splitParams, paramsOf, coordFrom, matchingBrace };
ddiff --git a/server/store.js b/server/store.js
nnew file mode 100644
iindex 0000000..2144e00
---- /dev/null
++++ b/server/store.js
@@@ -0,0 +1,604 @@
++'use strict';
++
++/**
++ * Tours' data: the open place database (cities, places, routes and their stops), the Live/VOD and Rent rows the
++ * jobs refresh, the job receipts the home shows, and a person's trips.
++ *
++ * Source rows carry their source URL and fetch time. A trip belongs to one Network subject; only its owner can
++ * change it, and only a shared trip is readable by anyone else. No row holds a token.
++ */
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++
++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const VISIBILITIES = Object.freeze(['private', 'shared']);
++const LIVE_KINDS = Object.freeze(['stream', 'vod', 'clip']);
++
++/** A stable id from a natural key, so re-running a source job updates the same page instead of orphaning it. */
++function stableId(prefix, key) {
++ const h = crypto.createHash('sha256').update(String(key)).digest();
++ let out = '';
++ for (let i = 0; i < 26; i += 1) out += ALPHABET[h[i] % 32];
++ return `${prefix}_${out}`;
++}
++
++function slugify(value) {
++ return String(value == null ? '' : value).normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
++ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 90);
++}
++
++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
++const day = (v) => (v == null ? null : (v instanceof Date ? v.toISOString().slice(0, 10) : String(v).slice(0, 10)));
++const num = (v) => (v == null ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
++const int = (v, def = 0) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const clean = (v, max = 1000) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
++const refusal = (status, code, message) => ({ ok: false, status, code, message });
++const isIsoDay = (v) => /^\d{4}-\d{2}-\d{2}$/.test(String(v || '')) && !Number.isNaN(Date.parse(`${v}T00:00:00Z`));
++
++function wireCity(row) {
++ if (!row) return null;
++ return {
++ id: row.id, slug: row.slug, name: row.name, country: row.country, country_code: row.country_code || null,
++ region: row.region || null, lat: num(row.lat), lon: num(row.lon), population: row.population == null ? null : Number(row.population),
++ wikidata_id: row.wikidata_id, wikipedia_title: row.wikipedia_title || null, wikivoyage_title: row.wikivoyage_title || null,
++ summary: row.summary || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
++ image_license: row.image_license || null, image_license_url: row.image_license_url || null,
++ source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
++ created_at: iso(row.created_at), updated_at: iso(row.updated_at),
++ ...(row.place_count != null ? { place_count: Number(row.place_count) } : {}),
++ ...(row.route_count != null ? { route_count: Number(row.route_count) } : {}),
++ };
++}
++
++function wirePlace(row) {
++ if (!row) return null;
++ return {
++ id: row.id, city_id: row.city_id, city_slug: row.city_slug || null, city_name: row.city_name || null,
++ osm_type: row.osm_type, osm_id: row.osm_id, name: row.name, kind: row.kind, lat: num(row.lat), lon: num(row.lon),
++ address: row.address || null, website: row.website || null, opening_hours: row.opening_hours || null,
++ wheelchair: row.wheelchair || null, fee: row.fee || null, wikidata_id: row.wikidata_id || null,
++ wikipedia: row.wikipedia || null, image_url: row.image_url || null, image_credit: row.image_credit || null,
++ image_license: row.image_license || null, description: row.description || null,
++ source: row.source, source_url: row.source_url, fetched_at: iso(row.fetched_at),
++ osm_url: row.osm_type === 'wikivoyage' ? null : `https://www.openstreetmap.org/${row.osm_type}/${row.osm_id}`,
++ map_url: `https://www.openstreetmap.org/?mlat=${row.lat}&mlon=${row.lon}#map=16/${row.lat}/${row.lon}`,
++ };
++}
++
++function wireRoute(row, extra = {}) {
++ if (!row) return null;
++ return {
++ id: row.id, city_id: row.city_id, city_slug: row.city_slug || null, city_name: row.city_name || null,
++ slug: row.slug, title: row.title, summary: row.summary || null, source: row.source, source_url: row.source_url,
++ fetched_at: iso(row.fetched_at), distance_m: Number(row.distance_m) || 0, duration_min: Number(row.duration_min) || 0,
++ stop_count: Number(row.stop_count) || 0, generated: row.generated !== false,
++ created_at: iso(row.created_at), updated_at: iso(row.updated_at), ...extra,
++ };
++}
++
++function wireStop(row) {
++ if (!row) return null;
++ return {
++ position: Number(row.position), place_id: row.place_id || null, title: row.title, note: row.note || null,
++ lat: num(row.lat), lon: num(row.lon), source: row.source, source_url: row.source_url,
++ audio_status: row.audio_status || 'none', audio_url: row.audio_url || null,
++ map_url: `https://www.openstreetmap.org/?mlat=${row.lat}&mlon=${row.lon}#map=17/${row.lat}/${row.lon}`,
++ };
++}
++
++function wireLive(row) {
++ if (!row) return null;
++ return {
++ id: row.id, kind: row.kind, external_id: row.external_id, username: row.username, display_name: row.display_name || null,
++ title: row.title, category: row.category || null, thumbnail_url: row.thumbnail_url || null,
++ viewer_count: Number(row.viewer_count) || 0, city_id: row.city_id || null, city_name: row.city_name || null,
++ url: row.url, started_at: iso(row.started_at), ended_at: iso(row.ended_at), fetched_at: iso(row.fetched_at),
++ };
++}
++
++function wireStay(row) {
++ if (!row) return null;
++ return {
++ id: row.id, external_id: row.external_id, city_id: row.city_id || null, city_name: row.city_name,
++ title: row.title, kind: row.kind, price: num(row.price), currency: row.currency, period: row.period,
++ neighbourhood: row.neighbourhood || null, url: row.url, fetched_at: iso(row.fetched_at),
++ };
++}
++
++function wireTrip(row) {
++ if (!row) return null;
++ return {
++ id: row.id, slug: row.slug, title: row.title, city_id: row.city_id || null,
++ starts_on: day(row.starts_on), ends_on: day(row.ends_on), notes: row.notes || null,
++ visibility: row.visibility, revision: Number(row.revision) || 1,
++ created_at: iso(row.created_at), updated_at: iso(row.updated_at),
++ };
++}
++
++function createStore(s, { config = {} } = {}) {
++ const db = s.db;
++ const q = (t) => t || db;
++
++ // ── Cities ──────────────────────────────────────────────
++ async function listCities({ limit = 100, offset = 0, q: query = null } = {}) {
++ const lim = Math.min(200, Math.max(1, int(limit, 100)));
++ const off = Math.max(0, int(offset, 0));
++ const term = query ? `%${clean(query, 80).replace(/[%_]/g, '')}%` : null;
++ const rows = await db.many(`
++ SELECT c.*,
++ (SELECT COUNT(*) FROM tours_places p WHERE p.city_id = c.id)::int AS place_count,
++ (SELECT COUNT(*) FROM tours_routes r WHERE r.city_id = c.id)::int AS route_count
++ FROM tours_cities c
++ ${term ? 'WHERE c.name ILIKE $3 OR c.country ILIKE $3' : ''}
++ ORDER BY c.population DESC NULLS LAST, c.name
++ LIMIT $1 OFFSET $2`, term ? [lim, off, term] : [lim, off]);
++ return rows.map(wireCity);
++ }
++
++ async function cityBySlug(slug) {
++ const row = await db.maybe(`
++ SELECT c.*,
++ (SELECT COUNT(*) FROM tours_places p WHERE p.city_id = c.id)::int AS place_count,
++ (SELECT COUNT(*) FROM tours_routes r WHERE r.city_id = c.id)::int AS route_count
++ FROM tours_cities c WHERE c.slug = $1`, [String(slug || '')]);
++ return wireCity(row);
++ }
++
++ async function cityPlaces(cityId, { limit = 60 } = {}) {
++ const rows = await db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
++ WHERE p.city_id = $1 ORDER BY p.kind, p.name LIMIT $2`, [String(cityId), Math.min(200, Math.max(1, int(limit, 60)))]);
++ return rows.map(wirePlace);
++ }
++
++ async function cityRoutes(cityId) {
++ const rows = await db.many(`SELECT r.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id
++ WHERE r.city_id = $1 ORDER BY r.stop_count DESC, r.id`, [String(cityId)]);
++ return rows.map((r) => wireRoute(r));
++ }
++
++ async function citiesForOsm(limit = 10) {
++ return await db.many(`SELECT id, slug, name, lat, lon FROM tours_cities
++ WHERE osm_fetched_at IS NULL OR osm_fetched_at < now() - interval '7 days'
++ ORDER BY population DESC NULLS LAST, id LIMIT $1`, [Math.min(100, Math.max(0, int(limit, 10)))]);
++ }
++
++ async function markCityOsmFetched(cityId) {
++ await db.exec('UPDATE tours_cities SET osm_fetched_at = now() WHERE id = $1', [String(cityId)]);
++ }
++
++ async function cityLive(cityId) {
++ const rows = await db.many(`SELECT * FROM tours_live WHERE city_id = $1 ORDER BY kind, fetched_at DESC LIMIT 12`, [String(cityId)]);
++ return rows.map(wireLive);
++ }
++
++ async function cityStays(cityId) {
++ const rows = await db.many(`SELECT * FROM tours_stays WHERE city_id = $1 ORDER BY price LIMIT 12`, [String(cityId)]);
++ return rows.map(wireStay);
++ }
++
++ // ── Places and routes ───────────────────────────────────
++ async function placeById(id) {
++ const row = await db.maybe(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_places p JOIN tours_cities c ON c.id = p.city_id WHERE p.id = $1`, [String(id || '')]);
++ return wirePlace(row);
++ }
++
++ async function routeById(id) {
++ const row = await db.maybe(`SELECT r.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id WHERE r.id = $1`, [String(id || '')]);
++ if (!row) return null;
++ const stops = await db.many('SELECT * FROM tours_route_stops WHERE route_id = $1 ORDER BY position', [row.id]);
++ return wireRoute(row, { stops: stops.map(wireStop) });
++ }
++
++ async function routesForPlace(placeId) {
++ const rows = await db.many(`SELECT DISTINCT r.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_route_stops s JOIN tours_routes r ON r.id = s.route_id JOIN tours_cities c ON c.id = r.city_id
++ WHERE s.place_id = $1 ORDER BY r.city_id, r.id`, [String(placeId || '')]);
++ return rows.map((r) => wireRoute(r));
++ }
++
++ async function routes({ limit = 12, cityId = null } = {}) {
++ const rows = cityId
++ ? await db.many(`SELECT r.*, c.slug AS city_slug, c.name AS city_name FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id
++ WHERE r.city_id = $1 ORDER BY r.stop_count DESC, r.id LIMIT $2`, [cityId, Math.min(100, Math.max(1, int(limit, 12)))])
++ : await db.many(`SELECT r.*, c.slug AS city_slug, c.name AS city_name FROM tours_routes r JOIN tours_cities c ON c.id = r.city_id
++ ORDER BY r.stop_count DESC, r.id LIMIT $1`, [Math.min(100, Math.max(1, int(limit, 12)))]);
++ return rows.map((r) => wireRoute(r));
++ }
++
++ /** The same place for everyone for a whole UTC day; no model call, no personalisation. */
++ async function placeOfTheDay(dateKey) {
++ const row = await db.maybe(`SELECT p.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_places p JOIN tours_cities c ON c.id = p.city_id
++ ORDER BY (p.image_url IS NOT NULL) DESC, (p.description IS NOT NULL) DESC, md5($1 || p.id) LIMIT 1`, [String(dateKey || new Date(s.now()).toISOString().slice(0, 10))]);
++ return wirePlace(row);
++ }
++
++ // ── Live and stays ──────────────────────────────────────
++ async function liveNow({ limit = 24 } = {}) {
++ const rows = await db.many(`SELECT * FROM tours_live WHERE kind = 'stream' ORDER BY viewer_count DESC, fetched_at DESC LIMIT $1`,
++ [Math.min(100, Math.max(1, int(limit, 24)))]);
++ return rows.map(wireLive);
++ }
++
++ async function recentTours({ limit = 12 } = {}) {
++ const rows = await db.many(`SELECT * FROM tours_live WHERE kind IN ('vod', 'clip') ORDER BY COALESCE(ended_at, fetched_at) DESC LIMIT $1`,
++ [Math.min(100, Math.max(1, int(limit, 12)))]);
++ return rows.map(wireLive);
++ }
++
++ async function stays({ limit = 12, cityId = null } = {}) {
++ const rows = cityId
++ ? await db.many('SELECT * FROM tours_stays WHERE city_id = $1 ORDER BY price LIMIT $2', [cityId, Math.min(100, Math.max(1, int(limit, 12)))])
++ : await db.many('SELECT * FROM tours_stays ORDER BY fetched_at DESC, price LIMIT $1', [Math.min(100, Math.max(1, int(limit, 12)))]);
++ return rows.map(wireStay);
++ }
++
++ // ── Counts, jobs and discovery ──────────────────────────
++ async function counts() {
++ const row = await db.maybe(`SELECT
++ (SELECT COUNT(*)::int FROM tours_cities) AS cities,
++ (SELECT COUNT(*)::int FROM tours_places) AS places,
++ (SELECT COUNT(*)::int FROM tours_routes) AS routes,
++ (SELECT COUNT(*)::int FROM tours_live WHERE kind = 'stream') AS live,
++ (SELECT COUNT(*)::int FROM tours_live WHERE kind IN ('vod', 'clip')) AS vods,
++ (SELECT COUNT(*)::int FROM tours_stays) AS stays`);
++ return row || { cities: 0, places: 0, routes: 0, live: 0, vods: 0, stays: 0 };
++ }
++
++ async function latestJobRuns() {
++ return await db.many(`SELECT DISTINCT ON (job) job, status, counts, error, finished_at
++ FROM tours_job_runs ORDER BY job, started_at DESC`);
++ }
++
++ async function recordJobStart(job) {
++ const row = await db.maybe(`INSERT INTO tours_job_runs (job, started_at, status) VALUES ($1, now(), 'running') RETURNING id`,
++ [String(job)]);
++ return row ? Number(row.id) : null;
++ }
++
++ async function recordJobFinish(id, { status = 'ok', counts: c = {}, error = null } = {}) {
++ if (id == null) return;
++ await db.exec(`UPDATE tours_job_runs SET finished_at = now(), status = $2, counts = $3::jsonb, error = $4 WHERE id = $1`,
++ [id, ['ok', 'partial', 'failed'].includes(status) ? status : 'partial', JSON.stringify(c || {}), error ? String(error).slice(0, 2000) : null]);
++ }
++
++ async function publicCitiesForSitemap(limit = 500) {
++ return await db.many('SELECT slug, updated_at FROM tours_cities ORDER BY population DESC NULLS LAST LIMIT $1', [limit]);
++ }
++ async function publicRoutesForSitemap(limit = 5000) {
++ return await db.many('SELECT id, updated_at FROM tours_routes ORDER BY id LIMIT $1', [limit]);
++ }
++ async function publicPlacesForSitemap(limit = 20000) {
++ return await db.many('SELECT id, updated_at FROM tours_places ORDER BY id LIMIT $1', [limit]);
++ }
++ async function publicCitiesForLlms(limit = 200) {
++ return await db.many('SELECT slug, name, country, summary FROM tours_cities ORDER BY population DESC NULLS LAST LIMIT $1', [limit]);
++ }
++
++ // ── Job upserts ─────────────────────────────────────────
++ async function upsertCity(c) {
++ const id = stableId('cty', c.wikidataId);
++ const slug = c.slug || slugify(c.name);
++ await db.exec(`INSERT INTO tours_cities
++ (id, slug, name, country, country_code, region, lat, lon, population, wikidata_id, wikipedia_title, wikivoyage_title,
++ summary, image_url, image_credit, image_license, image_license_url, source, source_url, fetched_at, updated_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,now())
++ ON CONFLICT (id) DO UPDATE SET
++ slug = EXCLUDED.slug, name = EXCLUDED.name, country = EXCLUDED.country, country_code = EXCLUDED.country_code,
++ region = EXCLUDED.region, lat = EXCLUDED.lat, lon = EXCLUDED.lon, population = EXCLUDED.population,
++ wikidata_id = EXCLUDED.wikidata_id, wikipedia_title = EXCLUDED.wikipedia_title, wikivoyage_title = EXCLUDED.wikivoyage_title,
++ summary = COALESCE(EXCLUDED.summary, tours_cities.summary), image_url = EXCLUDED.image_url,
++ image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license, image_license_url = EXCLUDED.image_license_url,
++ source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at, updated_at = now()`,
++ [id, slug, clean(c.name, 120), clean(c.country, 120), c.countryCode || null, c.region ? clean(c.region, 120) : null,
++ Number(c.lat), Number(c.lon), c.population == null ? null : Number(c.population), c.wikidataId,
++ c.wikipediaTitle || null, c.wikivoyageTitle || null, c.summary ? clean(c.summary, 1200) : null,
++ c.imageUrl || null, c.imageCredit ? clean(c.imageCredit, 250) : null, c.imageLicense ? clean(c.imageLicense, 120) : null,
++ c.imageLicenseUrl || null, c.source || 'wikidata', c.sourceUrl, new Date(c.fetchedAt || s.now()).toISOString()]);
++ return id;
++ }
++
++ async function upsertCitySource(cityId, { wikivoyageTitle, wikitext, sourceUrl, fetchedAt }) {
++ await db.exec(`INSERT INTO tours_city_sources (city_id, wikivoyage_title, wikitext, source_url, fetched_at)
++ VALUES ($1,$2,$3,$4,$5) ON CONFLICT (city_id) DO UPDATE SET
++ wikivoyage_title = EXCLUDED.wikivoyage_title, wikitext = EXCLUDED.wikitext,
++ source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at`,
++ [cityId, String(wikivoyageTitle), String(wikitext), sourceUrl, new Date(fetchedAt || s.now()).toISOString()]);
++ }
++
++ async function citySourcesMissing() {
++ return await db.many(`SELECT c.id, c.slug, c.name, c.wikivoyage_title, c.source_url FROM tours_cities c
++ LEFT JOIN tours_city_sources s ON s.city_id = c.id WHERE s.city_id IS NULL ORDER BY c.population DESC NULLS LAST`);
++ }
++
++ async function citySource(cityId) {
++ return await db.maybe('SELECT * FROM tours_city_sources WHERE city_id = $1', [String(cityId)]);
++ }
++
++ async function upsertPlace(p) {
++ const id = stableId('plc', `${p.osmType}:${p.osmId}`);
++ await db.exec(`INSERT INTO tours_places
++ (id, city_id, osm_type, osm_id, name, kind, lat, lon, address, website, opening_hours, wheelchair, fee,
++ wikidata_id, wikipedia, image_url, image_credit, image_license, description, source, source_url, fetched_at, updated_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,now())
++ ON CONFLICT (osm_type, osm_id) DO UPDATE SET
++ city_id = EXCLUDED.city_id, name = EXCLUDED.name, kind = EXCLUDED.kind, lat = EXCLUDED.lat, lon = EXCLUDED.lon,
++ address = EXCLUDED.address, website = EXCLUDED.website, opening_hours = EXCLUDED.opening_hours,
++ wheelchair = EXCLUDED.wheelchair, fee = EXCLUDED.fee, wikidata_id = EXCLUDED.wikidata_id, wikipedia = EXCLUDED.wikipedia,
++ image_url = EXCLUDED.image_url, image_credit = EXCLUDED.image_credit, image_license = EXCLUDED.image_license,
++ description = EXCLUDED.description, source = EXCLUDED.source, source_url = EXCLUDED.source_url,
++ fetched_at = EXCLUDED.fetched_at, updated_at = now()`,
++ [id, p.cityId, p.osmType, String(p.osmId), clean(p.name, 200), p.kind, Number(p.lat), Number(p.lon),
++ p.address ? clean(p.address, 250) : null, p.website || null, p.openingHours ? clean(p.openingHours, 200) : null,
++ p.wheelchair || null, p.fee || null, p.wikidataId || null, p.wikipedia || null, p.imageUrl || null,
++ p.imageCredit ? clean(p.imageCredit, 250) : null, p.imageLicense ? clean(p.imageLicense, 120) : null,
++ p.description ? clean(p.description, 800) : null, p.source || 'openstreetmap', p.sourceUrl,
++ new Date(p.fetchedAt || s.now()).toISOString()]);
++ return id;
++ }
++
++ async function replaceRoutesForCity(cityId, routeList) {
++ const keep = [];
++ for (const r of routeList) {
++ const id = r.id || stableId('rte', r.key);
++ keep.push(id);
++ await db.exec(`INSERT INTO tours_routes
++ (id, city_id, slug, title, summary, source, source_url, fetched_at, distance_m, duration_min, stop_count, generated, updated_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now())
++ ON CONFLICT (id) DO UPDATE SET
++ city_id = EXCLUDED.city_id, slug = EXCLUDED.slug, title = EXCLUDED.title, summary = EXCLUDED.summary,
++ source = EXCLUDED.source, source_url = EXCLUDED.source_url, fetched_at = EXCLUDED.fetched_at,
++ distance_m = EXCLUDED.distance_m, duration_min = EXCLUDED.duration_min, stop_count = EXCLUDED.stop_count,
++ generated = EXCLUDED.generated, updated_at = now()`,
++ [id, cityId, r.slug, clean(r.title, 160), r.summary ? clean(r.summary, 800) : null, 'wikivoyage', r.sourceUrl,
++ new Date(r.fetchedAt || s.now()).toISOString(), int(r.distanceM), int(r.durationMin), r.stops.length, true]);
++ await db.exec('DELETE FROM tours_route_stops WHERE route_id = $1', [id]);
++ for (const [i, stop] of r.stops.entries()) {
++ await db.exec(`INSERT INTO tours_route_stops
++ (route_id, position, place_id, title, note, lat, lon, source, source_url, audio_status, audio_url)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,'wikivoyage',$8,'none',NULL)`,
++ [id, i + 1, stop.placeId || null, clean(stop.title, 200), stop.note ? clean(stop.note, 600) : null,
++ Number(stop.lat), Number(stop.lon), r.sourceUrl]);
++ }
++ }
++ if (keep.length) await db.exec('DELETE FROM tours_routes WHERE city_id = $1 AND NOT (id = ANY($2::text[]))', [cityId, keep]);
++ else await db.exec('DELETE FROM tours_routes WHERE city_id = $1', [cityId]);
++ return keep.length;
++ }
++
++ async function upsertLive(item) {
++ const id = stableId('liv', `${item.kind}:${item.externalId}`);
++ await db.exec(`INSERT INTO tours_live
++ (id, kind, external_id, username, display_name, title, category, thumbnail_url, viewer_count, city_id, city_name,
++ url, started_at, ended_at, fetched_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
++ ON CONFLICT (kind, external_id) DO UPDATE SET
++ username = EXCLUDED.username, display_name = EXCLUDED.display_name, title = EXCLUDED.title,
++ category = EXCLUDED.category, thumbnail_url = EXCLUDED.thumbnail_url, viewer_count = EXCLUDED.viewer_count,
++ city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, url = EXCLUDED.url,
++ started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at, fetched_at = EXCLUDED.fetched_at`,
++ [id, item.kind, String(item.externalId), item.username, item.displayName ? clean(item.displayName, 80) : null,
++ clean(item.title, 240), item.category ? clean(item.category, 40) : null, item.thumbnailUrl || null,
++ Math.max(0, int(item.viewerCount)), item.cityId || null, item.cityName ? clean(item.cityName, 120) : null,
++ item.url, item.startedAt ? new Date(item.startedAt).toISOString() : null,
++ item.endedAt ? new Date(item.endedAt).toISOString() : null, new Date(item.fetchedAt || s.now()).toISOString()]);
++ return id;
++ }
++
++ async function clearLiveStreams() {
++ await db.exec("DELETE FROM tours_live WHERE kind = 'stream'");
++ }
++ async function trimLiveVods(keep = 200) {
++ await db.exec(`DELETE FROM tours_live WHERE kind = 'vod' AND id NOT IN
++ (SELECT id FROM tours_live WHERE kind IN ('vod', 'clip') ORDER BY COALESCE(ended_at, fetched_at) DESC LIMIT $1)`, [keep]);
++ }
++
++ async function upsertStay(st) {
++ const id = stableId('sty', st.externalId);
++ await db.exec(`INSERT INTO tours_stays
++ (id, external_id, city_id, city_name, title, kind, price, currency, period, neighbourhood, url, fetched_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
++ ON CONFLICT (external_id) DO UPDATE SET
++ city_id = EXCLUDED.city_id, city_name = EXCLUDED.city_name, title = EXCLUDED.title, kind = EXCLUDED.kind,
++ price = EXCLUDED.price, currency = EXCLUDED.currency, period = EXCLUDED.period,
++ neighbourhood = EXCLUDED.neighbourhood, url = EXCLUDED.url, fetched_at = EXCLUDED.fetched_at`,
++ [id, st.externalId, st.cityId || null, clean(st.cityName, 120), clean(st.title, 200), st.kind,
++ Number(st.price), st.currency, st.period, st.neighbourhood ? clean(st.neighbourhood, 120) : null,
++ st.url, new Date(st.fetchedAt || s.now()).toISOString()]);
++ return id;
++ }
++
++ // ── Trips ───────────────────────────────────────────────
++ function tripFields(input, base = null) {
++ const v = { ...base };
++ if (input.title !== undefined) v.title = clean(input.title, 120);
++ if (input.city_slug !== undefined) v.city_slug = clean(input.city_slug, 90);
++ if (input.starts_on !== undefined) v.starts_on = String(input.starts_on || '');
++ if (input.ends_on !== undefined) v.ends_on = String(input.ends_on || '');
++ if (input.notes !== undefined) v.notes = clean(input.notes, 2000);
++ if (input.visibility !== undefined) v.visibility = String(input.visibility || '');
++ return v;
++ }
++
++ async function validateTrip(v, { partial = false } = {}) {
++ if (!partial || v.title !== undefined) {
++ if (String(v.title || '').length < 3 || String(v.title || '').length > 120) return refusal(400, 'tours.trip.invalid', 'title must be 3 to 120 characters');
++ }
++ if (!partial || v.starts_on !== undefined) {
++ if (!isIsoDay(v.starts_on)) return refusal(400, 'tours.trip.invalid', 'starts_on must be a date (YYYY-MM-DD)');
++ }
++ if (!partial || v.ends_on !== undefined) {
++ if (!isIsoDay(v.ends_on)) return refusal(400, 'tours.trip.invalid', 'ends_on must be a date (YYYY-MM-DD)');
++ }
++ if (isIsoDay(v.starts_on) && isIsoDay(v.ends_on)) {
++ const days = (Date.parse(`${v.ends_on}T00:00:00Z`) - Date.parse(`${v.starts_on}T00:00:00Z`)) / 86_400_000;
++ if (days < 0) return refusal(400, 'tours.trip.invalid', 'ends_on must not be before starts_on');
++ if (days > 60) return refusal(400, 'tours.trip.invalid', 'a trip may be at most 60 days');
++ }
++ if (v.notes != null && String(v.notes).length > 2000) return refusal(400, 'tours.trip.invalid', 'notes are at most 2000 characters');
++ if (v.visibility !== undefined && v.visibility !== null && !VISIBILITIES.includes(v.visibility)) return refusal(400, 'tours.trip.invalid', 'visibility must be private or shared');
++ return { ok: true };
++ }
++
++ async function createTrip({ subject, title, citySlug, startsOn, endsOn, notes = '', visibility = 'private' }) {
++ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(403, 'tours.person_required', 'a person is required');
++ const v = tripFields({ title, city_slug: citySlug, starts_on: startsOn, ends_on: endsOn, notes, visibility });
++ if (!v.visibility) v.visibility = 'private';
++ const check = await validateTrip(v);
++ if (!check.ok) return check;
++ const city = await cityBySlug(v.city_slug);
++ if (!city) return refusal(400, 'tours.trip.invalid', 'city_slug must name a city Tours knows');
++ const id = `trp_${ids.ulid(s.now())}`;
++ const slug = `trip-${crypto.randomBytes(12).toString('hex')}`;
++ const row = await db.maybe(`INSERT INTO tours_trips (id, slug, owner_subject, title, city_id, starts_on, ends_on, notes, visibility)
++ VALUES ($1,$2,$3,$4,$5,$6::date,$7::date,$8,$9) RETURNING *`,
++ [id, slug, subject, v.title, city.id, v.starts_on, v.ends_on, v.notes || null, v.visibility]);
++ return { ok: true, trip: wireTrip(row), city };
++ }
++
++ async function tripsForSubject(subject) {
++ const rows = await db.many(`SELECT t.*, c.slug AS city_slug, c.name AS city_name
++ FROM tours_trips t LEFT JOIN tours_cities c ON c.id = t.city_id
++ WHERE t.owner_subject = $1 ORDER BY t.starts_on DESC LIMIT 100`, [String(subject || '')]);
++ return rows.map((r) => ({ ...wireTrip(r), city_slug: r.city_slug || null, city_name: r.city_name || null }));
++ }
++
++ async function tripBySlug(slug, { subject = null } = {}) {
++ const row = await db.maybe(`SELECT t.*, c.slug AS city_slug, c.name AS city_name, c.lat AS city_lat, c.lon AS city_lon
++ FROM tours_trips t LEFT JOIN tours_cities c ON c.id = t.city_id WHERE t.slug = $1`, [String(slug || '')]);
++ if (!row) return null;
++ const mine = subject && row.owner_subject === subject;
++ if (!mine && row.visibility !== 'shared') return null;
++ const [places, route, staysRows] = await Promise.all([
++ db.many(`SELECT p.*, c.slug AS city_slug, c.name AS city_name, tp.position, tp.note AS trip_note
++ FROM tours_trip_places tp JOIN tours_places p ON p.id = tp.place_id JOIN tours_cities c ON c.id = p.city_id
++ WHERE tp.trip_id = $1 ORDER BY tp.position`, [row.id]),
++ db.maybe(`SELECT r.*, tr.added_at FROM tours_trip_routes tr JOIN tours_routes r ON r.id = tr.route_id WHERE tr.trip_id = $1`, [row.id]),
++ db.many(`SELECT s.*, ts.added_at FROM tours_trip_stays ts JOIN tours_stays s ON s.id = ts.stay_id WHERE ts.trip_id = $1 ORDER BY ts.added_at`, [row.id]),
++ ]);
++ let routeOut = null;
++ if (route) {
++ const stops = await db.many('SELECT * FROM tours_route_stops WHERE route_id = $1 ORDER BY position', [route.id]);
++ routeOut = wireRoute(route, { stops: stops.map(wireStop) });
++ }
++ return {
++ trip: { ...wireTrip(row), city_slug: row.city_slug || null, city_name: row.city_name || null,
++ city_lat: num(row.city_lat), city_lon: num(row.city_lon) },
++ mine: Boolean(mine),
++ places: places.map((p) => ({ ...wirePlace(p), position: Number(p.position), trip_note: p.trip_note || null })),
++ route: routeOut,
++ stays: staysRows.map(wireStay),
++ };
++ }
++
++ async function updateTrip({ subject, slug, input }) {
++ const row = await db.maybe('SELECT * FROM tours_trips WHERE slug = $1', [String(slug || '')]);
++ if (!row) return refusal(404, 'tours.trip_not_found', 'no such trip');
++ if (row.owner_subject !== subject) return refusal(403, 'tours.trip_owner', 'only the person who made this trip can change it');
++ const base = {
++ title: row.title, city_slug: null, starts_on: day(row.starts_on), ends_on: day(row.ends_on),
++ notes: row.notes || '', visibility: row.visibility,
++ };
++ const merged = tripFields(input || {}, base);
++ if (input && input.city_slug !== undefined) {
++ const city = await cityBySlug(merged.city_slug);
++ if (!city) return refusal(400, 'tours.trip.invalid', 'city_slug must name a city Tours knows');
++ merged.city_id = city.id;
++ } else merged.city_id = row.city_id;
++ const check = await validateTrip(merged);
++ if (!check.ok) return check;
++ const updated = await db.maybe(`UPDATE tours_trips SET title=$2, city_id=$3, starts_on=$4::date, ends_on=$5::date,
++ notes=$6, visibility=$7, revision=revision+1, updated_at=now() WHERE id=$1 RETURNING *`,
++ [row.id, merged.title, merged.city_id, merged.starts_on, merged.ends_on, merged.notes || null, merged.visibility]);
++ return { ok: true, trip: wireTrip(updated) };
++ }
++
++ async function deleteTrip({ subject, slug }) {
++ const row = await db.maybe('SELECT * FROM tours_trips WHERE slug = $1', [String(slug || '')]);
++ if (!row) return refusal(404, 'tours.trip_not_found', 'no such trip');
++ if (row.owner_subject !== subject) return refusal(403, 'tours.trip_owner', 'only the person who made this trip can change it');
++ await db.exec('DELETE FROM tours_trips WHERE id = $1', [row.id]);
++ return { ok: true };
++ }
++
++ async function ownedTrip(subject, slug) {
++ const row = await db.maybe('SELECT * FROM tours_trips WHERE slug = $1', [String(slug || '')]);
++ if (!row) return { error: refusal(404, 'tours.trip_not_found', 'no such trip') };
++ if (row.owner_subject !== subject) return { error: refusal(403, 'tours.trip_owner', 'only the person who made this trip can change it') };
++ return { row };
++ }
++
++ async function addTripPlace({ subject, slug, placeId, note = null }) {
++ const own = await ownedTrip(subject, slug);
++ if (own.error) return own.error;
++ const place = await placeById(placeId);
++ if (!place) return refusal(404, 'tours.place_not_found', 'no such place');
++ const next = await db.value('SELECT COALESCE(MAX(position), 0) + 1 FROM tours_trip_places WHERE trip_id = $1', [own.row.id]);
++ await db.exec(`INSERT INTO tours_trip_places (trip_id, place_id, position, note) VALUES ($1,$2,$3,$4)
++ ON CONFLICT (trip_id, place_id) DO UPDATE SET note = EXCLUDED.note`, [own.row.id, place.id, Number(next), note ? clean(note, 500) : null]);
++ await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
++ return { ok: true, place };
++ }
++
++ async function removeTripPlace({ subject, slug, placeId }) {
++ const own = await ownedTrip(subject, slug);
++ if (own.error) return own.error;
++ await db.exec('DELETE FROM tours_trip_places WHERE trip_id = $1 AND place_id = $2', [own.row.id, String(placeId || '')]);
++ await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
++ return { ok: true };
++ }
++
++ async function setTripRoute({ subject, slug, routeId }) {
++ const own = await ownedTrip(subject, slug);
++ if (own.error) return own.error;
++ if (!routeId) {
++ await db.exec('DELETE FROM tours_trip_routes WHERE trip_id = $1', [own.row.id]);
++ } else {
++ const route = await routeById(routeId);
++ if (!route) return refusal(404, 'tours.route_not_found', 'no such route');
++ if (own.row.city_id && route.city_id !== own.row.city_id) return refusal(400, 'tours.trip.invalid', 'the route must be in the trip\'s city');
++ await db.exec(`INSERT INTO tours_trip_routes (trip_id, route_id) VALUES ($1,$2)
++ ON CONFLICT (trip_id) DO UPDATE SET route_id = EXCLUDED.route_id, added_at = now()`, [own.row.id, route.id]);
++ }
++ await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
++ return { ok: true };
++ }
++
++ async function addTripStay({ subject, slug, stayId }) {
++ const own = await ownedTrip(subject, slug);
++ if (own.error) return own.error;
++ const stay = await db.maybe('SELECT * FROM tours_stays WHERE id = $1', [String(stayId || '')]);
++ if (!stay) return refusal(404, 'tours.stay_not_found', 'no such stay');
++ if (own.row.city_id && stay.city_id && stay.city_id !== own.row.city_id) return refusal(400, 'tours.trip.invalid', 'the stay must be in the trip\'s city');
++ await db.exec('INSERT INTO tours_trip_stays (trip_id, stay_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [own.row.id, stay.id]);
++ await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
++ return { ok: true, stay: wireStay(stay) };
++ }
++
++ async function removeTripStay({ subject, slug, stayId }) {
++ const own = await ownedTrip(subject, slug);
++ if (own.error) return own.error;
++ await db.exec('DELETE FROM tours_trip_stays WHERE trip_id = $1 AND stay_id = $2', [own.row.id, String(stayId || '')]);
++ await db.exec('UPDATE tours_trips SET revision = revision + 1, updated_at = now() WHERE id = $1', [own.row.id]);
++ return { ok: true };
++ }
++
++ return {
++ counts, listCities, cityBySlug, cityPlaces, cityRoutes, cityLive, cityStays, citiesForOsm, markCityOsmFetched,
++ placeById, routeById, routes, routesForPlace, placeOfTheDay, liveNow, recentTours, stays,
++ latestJobRuns, recordJobStart, recordJobFinish,
++ publicCitiesForSitemap, publicRoutesForSitemap, publicPlacesForSitemap, publicCitiesForLlms,
++ upsertCity, upsertCitySource, citySourcesMissing, citySource, upsertPlace, replaceRoutesForCity,
++ upsertLive, clearLiveStreams, trimLiveVods, upsertStay,
++ createTrip, tripsForSubject, tripBySlug, updateTrip, deleteTrip,
++ addTripPlace, removeTripPlace, setTripRoute, addTripStay, removeTripStay,
++ };
++}
++
++module.exports = { createStore, stableId, slugify, wireCity, wirePlace, wireRoute, wireStop, wireLive, wireStay, wireTrip, SLUG_RE, SUBJECT_RE, VISIBILITIES, LIVE_KINDS };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..b005db0
---- /dev/null
++++ b/server/views/index.js
@@@ -0,0 +1,425 @@
++'use strict';
++
++/**
++ * Tours' server-rendered pages. They work without JavaScript: every link is real, every action is a plain form.
++ * Public pages are public, max-age=60 with Vary: Cookie; a page drawn for a signed-in person or a private trip is
++ * private, no-store. The route page adds a MapLibre map from a CDN on top of the complete stop list and its SVG.
++ *
++ * Ids and classes start with `tr-`.
++ */
++const { esc, raw } = require('../render/html');
++const sc = require('openvibe-shared/showcase');
++const kit = require('openvibe-shared/kit');
++const { stateLabel } = require('./labels');
++
++const SOURCE = {
++ osm: '<a href="https://www.openstreetmap.org/copyright" rel="noopener">© OpenStreetMap contributors</a>, ODbL 1.0',
++ wikivoyage: '<a href="https://en.wikivoyage.org" rel="noopener">Wikivoyage</a>, CC BY-SA 4.0',
++ wikidata: '<a href="https://www.wikidata.org" rel="noopener">Wikidata</a>, CC0',
++};
++
++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 km = (m) => (Number(m) >= 1000 ? `${(Number(m) / 1000).toFixed(1)} km` : `${Math.round(Number(m) || 0)} m`);
++const safeHttp = (v) => (typeof v === 'string' && /^https?:\/\//i.test(v) ? v : null);
++const money = (n, c) => `${Number(n).toLocaleString('en-US', { maximumFractionDigits: 2 })} ${c}`;
++const pct = (n, total) => (total ? Math.round((Number(n) / Number(total)) * 100) : 0);
++
++function empty(text, href = null, label = '') {
++ return `<div class="tr-empty"><p>${esc(text)}</p>${href ? `<a class="tr-btn tr-btn-quiet" href="${esc(href)}">${esc(label)}</a>` : ''}</div>`;
++}
++
++function note(html, kind = 'info') {
++ return `<p class="tr-note tr-note-${esc(kind)}" role="${kind === 'error' ? 'alert' : 'status'}">${html}</p>`;
++}
++
++/** The hero's drawing: a plane's dotted arc landing on a map, then the walking route. */
++function heroMotif() {
++ return `<div class="tr-vis" aria-hidden="true">
++<svg viewBox="0 0 420 320" class="tr-vis-svg">
++<defs><linearGradient id="tr-arc" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="var(--room-accent,#F98942)"/><stop offset="1" stop-color="var(--accent-light,#60a5fa)"/></linearGradient></defs>
++<path class="tr-grid" d="M0 60 H420 M0 150 H420 M0 240 H420 M70 0 V320 M210 0 V320 M350 0 V320"/>
++<path class="tr-arc" d="M28 250 Q150 40 330 92" fill="none" stroke="url(#tr-arc)" stroke-width="3" stroke-dasharray="7 8"/>
++<circle class="tr-land" cx="330" cy="92" r="8"/>
++<path class="tr-walk" d="M92 230 L150 200 L188 224 L238 178 L292 194 L318 158" fill="none" stroke="var(--room-accent,#F98942)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
++${[[92, 230], [150, 200], [188, 224], [238, 178], [292, 194], [318, 158]].map(([x, y], i) => `<g><circle cx="${x}" cy="${y}" r="${i ? 6 : 9}" class="tr-pin"/><text x="${x}" y="${y + 4}" text-anchor="middle" class="tr-pin-n">${i + 1}</text></g>`).join('')}
++</svg>
++<span class="tr-vis-badge">fly · stay · walk · watch</span></div>`;
++}
++
++const cityCard = (c) => kit.card({
++ href: `/city/${c.slug}`,
++ title: c.name,
++ type: 'map',
++ image: safeHttp(c.image_url) || '',
++ alt: c.image_credit ? `${c.name} (${c.image_credit})` : c.name,
++ badge: c.country || '',
++ who: c.country || '',
++ count: [Number(c.place_count) || 0, 'places'],
++ peek: c.summary ? String(c.summary).slice(0, 120) : '',
++ level: 3,
++});
++
++const placeCard = (p) => kit.card({
++ href: `/place/${p.id}`,
++ title: p.name,
++ type: 'map',
++ image: safeHttp(p.image_url) || '',
++ badge: p.kind ? p.kind.replace(/_/g, ' ') : '',
++ who: p.city_name || '',
++ peek: p.description ? String(p.description).slice(0, 120) : '',
++ level: 3,
++});
++
++const routeCard = (r) => kit.card({
++ href: `/routes/${r.id}`,
++ title: r.title,
++ type: 'map',
++ badge: r.generated ? 'Wikivoyage' : '',
++ who: r.city_name || '',
++ count: [Number(r.stop_count) || 0, 'stops'],
++ corner: `${km(r.distance_m)} · ${Number(r.duration_min) || 0} min`,
++ peek: r.summary ? String(r.summary).slice(0, 140) : '',
++ level: 3,
++});
++
++const liveCard = (l) => kit.card({
++ href: l.url,
++ title: l.title,
++ type: 'stream',
++ image: safeHttp(l.thumbnail_url) || '',
++ live: l.kind === 'stream',
++ badge: l.kind === 'clip' ? 'CLIP' : l.kind === 'vod' ? 'VOD' : '',
++ who: l.display_name || l.username,
++ count: l.kind === 'stream' ? [Number(l.viewer_count) || 0, 'watching'] : null,
++ when: l.started_at || l.ended_at,
++ peek: l.city_name ? `In ${l.city_name}` : '',
++ level: 3,
++});
++
++const stayCard = (s) => kit.card({
++ href: s.url,
++ title: s.title,
++ type: 'listing',
++ badge: String(s.kind || '').replace(/_/g, ' '),
++ who: s.city_name,
++ corner: `${money(s.price, s.currency)} / ${s.period}`,
++ peek: s.neighbourhood || '',
++ level: 3,
++});
++
++function placeOfDayCard(p) {
++ if (!p) return '';
++ return `<article class="tr-pod" data-place="${esc(p.id)}">
++<a class="tr-pod-link" href="/place/${esc(p.id)}">
++${safeHttp(p.image_url) ? `<img src="${esc(p.image_url)}" alt="${esc(p.name)}" loading="lazy" decoding="async">` : '<span class="tr-pod-noimg" aria-hidden="true">◆</span>'}
++<span class="tr-pod-body"><span class="tr-kicker">Place of the day · ${esc(p.city_name || '')}</span><strong>${esc(p.name)}</strong>
++<span class="tr-muted">${esc(p.kind ? p.kind.replace(/_/g, ' ') : 'place')}${p.description ? ` · ${esc(String(p.description).slice(0, 150))}` : ''}</span></span>
++</a></article>`;
++}
++
++function home({ counts = {}, live = [], recent = [], placeOfDay = null, cities = [], routes = [], stays = [], jobs = [] } = {}) {
++ const rails = [
++ { id: 'tr-live', title: 'Live tours now', more: { href: '/live', label: 'All live tours' }, items: live.length ? live.map(liveCard) : ['<p class="tr-muted">No IRL tour is live on OpenVibe.Live right now. Recent recordings are below.</p>'], size: 'm' },
++ !live.length && recent.length ? { id: 'tr-recent', title: 'Recent IRL recordings and clips', more: { href: '/live', label: 'All live tours' }, items: recent.map(liveCard), size: 'm' } : null,
++ placeOfDay ? { id: 'tr-place-of-day', title: 'Place of the day', items: [placeOfDayCard(placeOfDay)], size: 'l' } : null,
++ cities.length ? { id: 'tr-cities', title: 'Top cities', more: { href: '/cities', label: 'All cities' }, items: cities.map(cityCard), size: 'm' } : null,
++ routes.length ? { id: 'tr-routes', title: 'Self-guided routes', more: { href: '/routes', label: 'All routes' }, items: routes.map(routeCard), size: 'm' } : null,
++ { id: 'tr-stays', title: 'Stays', more: { href: '/stays', label: 'All stays' }, items: stays.length ? stays.map(stayCard) : ['<p class="tr-muted">OpenVibe.Rent has no active listing in these cities today. The shelf fills as people post.</p>'], size: 'm' },
++ ].filter(Boolean);
++ const lastFetch = jobs.map((j) => j.finished_at).filter(Boolean).sort().pop();
++ const proof = [
++ Number(counts.cities) ? { value: Number(counts.cities), label: 'cities' } : null,
++ Number(counts.places) ? { value: Number(counts.places), label: 'places' } : null,
++ Number(counts.routes) ? { value: Number(counts.routes), label: 'walking routes' } : null,
++ Number(counts.live) ? { value: Number(counts.live), label: 'live now', live: true } : null,
++ ].filter(Boolean);
++ const room = sc.room({
++ room: 'tours',
++ hero: {
++ title: 'Go anywhere: fly, stay, walk, watch',
++ lede: 'Places from OpenStreetMap, notes from Wikivoyage, coordinates and pictures from Wikidata, IRL tours live on OpenVibe.Live, stays from OpenVibe.Rent, and a map for every walk. All open data, each with its source and fetch time.',
++ search: { action: '/cities', name: 'q', placeholder: 'A city: Lisbon, Tokyo, Mexico City…', hints: ['Lisbon', 'Tokyo', 'Mexico City', 'Barcelona'], label: 'Find a city' },
++ chips: [{ label: 'Live tours', href: '/live' }, { label: 'Routes', href: '/routes' }, { label: 'Stays', href: '/stays' }, { label: 'Flights', href: '/flights' }],
++ proof,
++ motif: heroMotif(),
++ },
++ rails,
++ scenes: [
++ {
++ title: 'Walk a city with a cited note at every stop',
++ text: 'A route is ordered stops on a map, generated from Wikivoyage\'s See lists and marked as generated. Every stop links the Wikivoyage page it came from, the OpenStreetMap place it matches, and its coordinates.',
++ html: `<div class="tr-demo-walk"><span class="tr-demo-stop">1 · Castelo de São Jorge</span><span class="tr-demo-line"></span><span class="tr-demo-stop">2 · Lisbon Cathedral</span><span class="tr-demo-line"></span><span class="tr-demo-stop">3 · Miradouro de Santa Luzia</span><small>Wikivoyage CC BY-SA · OpenStreetMap ODbL</small></div>`,
++ action: { href: '/routes', label: 'Find a walk' },
++ },
++ {
++ title: 'Watch a real place, now',
++ text: 'Live tours are IRL streams from OpenVibe.Live\'s public stream list, with the viewer count and the city when the title or tags name one. Recorded IRL tours link to their VOD and clips to openvibe.video. Tours never posts to Live or chat.',
++ html: `<div class="tr-demo-live"><span class="tr-live-dot"></span><b>Live now</b><span>Category IRL · OpenVibe.Live</span><small>No follow, no write, no token.</small></div>`,
++ action: { href: '/live', label: 'Watch live tours' },
++ },
++ {
++ title: 'Build a trip and share it read-only',
++ text: 'Sign in with OpenVibe, pick a city and dates, save places, add a route and a stay, then share the link. Only you can edit it; anyone with the link can read it. Group voting is not in v1.',
++ html: `<div class="tr-demo-trip"><span class="tr-trip-day">Fri</span><b>Land · check in</b><span class="tr-trip-day">Sat</span><b>Walk the See list</b><span class="tr-trip-day">Sun</span><b>Market, then home</b><small>Private by default · shared on purpose</small></div>`,
++ action: { href: '/trips/new', label: 'Plan a trip' },
++ },
++ ],
++ explore: {
++ title: 'What a trip is made of',
++ items: [
++ { title: 'Cities', text: 'The top 100 first, from Wikidata coordinates and Wikivoyage pages.', href: '/cities', icon: 'map' },
++ { title: 'Places', text: 'Tourism=* from OpenStreetMap, with the OSM link and fetch time.', href: '/cities', icon: 'pin' },
++ { title: 'Live tours', text: 'IRL streams now and recent VODs from OpenVibe.Live.', href: '/live', icon: 'live' },
++ { title: 'Stays', text: 'Rentals from OpenVibe.Rent; no payment is handled here.', href: '/stays', icon: 'home' },
++ { title: 'Flights', text: 'No key yet: a form opens the official airline-neutral search.', href: '/flights', icon: 'plane' },
++ { title: 'Trips', text: 'A city, dates, saved places, a route and stays — shareable read-only.', href: '/trips', icon: 'calendar' },
++ ],
++ },
++ dev: {
++ title: 'The API',
++ lede: 'Everything the pages show is JSON under /api/v1, with an OpenAPI document. A trip write is a signed-in person; a public read needs no token.',
++ samples: [
++ { label: 'Cities', lang: 'bash', code: 'curl https://openvibe.tours/api/v1/cities\ncurl https://openvibe.tours/api/v1/cities/lisbon' },
++ { label: 'Places and routes', lang: 'bash', code: 'curl https://openvibe.tours/api/v1/places/plc_…\ncurl https://openvibe.tours/api/v1/routes/rte_…' },
++ { label: 'Live', lang: 'bash', code: 'curl https://openvibe.tours/api/v1/live' },
++ { label: 'OpenAPI', lang: 'json', code: 'GET /api/v1/openapi.json' },
++ ],
++ },
++ faq: {
++ items: [
++ ['Where does the data come from?', 'OpenStreetMap (Overpass, tourism=*) under ODbL, Wikivoyage under CC BY-SA, Wikidata under CC0, OpenVibe.Live\'s public stream list and OpenVibe.Rent\'s public listings. Every city, place, route and source row shows its source and fetch time.'],
++ ['Are the routes real guided tours?', 'No. They are self-guided walks generated from Wikivoyage\'s See lists and marked as generated. Check opening hours and access before you go; the note at each stop is the Wikivoyage text, cited.'],
++ ['Do you sell flights or stays?', 'No. There is no flight API key in v1: the flight form opens the official airline-neutral search with the city pair filled in, with no affiliate parameter. Stays link to the listing on OpenVibe.Rent; Tours never takes a payment.'],
++ ['Is there audio on the stops?', 'Not yet. OpenVibe.AI has no speech-synthesis route today, so every stop says audio is off instead of shipping a broken player. The audio fields are in the schema for when an API exists.'],
++ ['Who can see my trip?', 'Only you, until you switch it to shared. A shared trip is readable by anyone with its link and editable only by you. Delete it at any time; account deletion erases your trips.'],
++ ['Why is a city missing?', 'The v1 catalogue is the top 100 cities by population; every city is fetched from Wikidata and Wikivoyage. A failed source run leaves the previous data and says so in the job receipt rather than inventing a place.'],
++ ],
++ },
++ });
++ const jobsLine = jobs.length
++ ? `<section class="tr-sec tr-sources" aria-labelledby="tr-sources-h"><h2 id="tr-sources-h">Sources and fetch times</h2><p class="tr-muted">${esc(SOURCE.osm.replace(/<[^>]+>/g, ''))} · Wikivoyage CC BY-SA 4.0 · Wikidata CC0 · OpenVibe.Live · OpenVibe.Rent. Last source run ${lastFetch ? when(lastFetch) : 'not yet'}.</p><ul class="tr-jobs">${jobs.map((j) => `<li><b>${esc(j.job)}</b> ${esc(stateLabel(j.status))}${j.finished_at ? ` · ${when(j.finished_at)}` : ''}${j.error ? ` · ${esc(String(j.error).slice(0, 200))}` : ''}</li>`).join('')}</ul></section>`
++ : '';
++ return `<div class="tr-wrap tr-home" id="tr-home">${room}${jobsLine}</div>`;
++}
++
++function citiesPage({ cities = [], q = '' } = {}) {
++ return `<div class="tr-wrap" id="tr-cities-page">
++<header class="tr-page-h"><p class="tr-kicker">Cities</p><h1>The top 100, from open data</h1>
++<p class="tr-lede">Coordinates and population from Wikidata, the page and its See list from Wikivoyage, places from OpenStreetMap. A city page lists its places, routes, live tours and stays.</p>
++<form class="tr-form tr-form-inline" method="get" action="/cities" role="search"><label class="tr-field"><span>Find a city</span><input type="search" name="q" value="${esc(q)}" maxlength="80" placeholder="Lisbon"></label><button class="tr-btn" type="submit">Search</button></form></header>
++${cities.length ? `<ol class="tr-city-list">${cities.map((c) => `<li class="tr-city-row"><a href="/city/${esc(c.slug)}">
++<span class="tr-city-photo">${safeHttp(c.image_url) ? `<img src="${esc(c.image_url)}" alt="" loading="lazy" decoding="async">` : '<span aria-hidden="true">◆</span>'}</span>
++<span class="tr-city-text"><strong>${esc(c.name)}</strong><span class="tr-muted">${esc(c.country)}${c.population ? ` · ${Number(c.population).toLocaleString('en-US')} people` : ''}</span>
++<span class="tr-muted">${Number(c.place_count) || 0} places · ${Number(c.route_count) || 0} routes</span></span></a></li>`).join('')}</ol>` : empty('No city matched that search.', '/cities', 'See all cities')}
++<p class="tr-fine">${SOURCE.wikidata} · ${SOURCE.wikivoyage} · ${SOURCE.osm}</p>
++</div>`;
++}
++
++function cityPage({ city, places = [], routes = [], live = [], stays = [] } = {}) {
++ const img = safeHttp(city.image_url);
++ return `<div class="tr-wrap" id="tr-city-page">
++<header class="tr-page-h tr-city-head">
++<p class="tr-kicker">${esc(city.country)}${city.region ? ` · ${esc(city.region)}` : ''}</p>
++<h1>${esc(city.name)}</h1>
++${city.summary ? `<p class="tr-lede">${esc(city.summary)}</p>` : note('No Wikivoyage summary was fetched for this city yet.', 'warn')}
++<p class="tr-fine">${city.population ? `${Number(city.population).toLocaleString('en-US')} people · ` : ''}<a href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=11/${city.lat}/${city.lon}" rel="noopener">Map</a> · <a href="${esc(city.source_url)}" rel="noopener">${esc(city.source === 'wikivoyage' ? 'Wikivoyage' : 'Wikidata')}</a> · fetched ${when(city.fetched_at)}</p>
++${img ? `<figure class="tr-city-img"><img src="${esc(img)}" alt="${esc(city.name)}" loading="lazy" decoding="async"><figcaption>${esc(city.image_credit || 'Wikimedia Commons')}${city.image_license ? ` · ${esc(city.image_license)}` : ''}${city.image_license_url ? ` · <a href="${esc(city.image_license_url)}" rel="noopener">licence</a>` : ''}</figcaption></figure>` : ''}
++</header>
++${routes.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Self-guided routes</h2><span class="tr-muted">generated from Wikivoyage's See list</span></div><ol class="tr-route-list">${routes.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${Number(r.stop_count)} stops · ${km(r.distance_m)} · ${Number(r.duration_min)} min</span></a></li>`).join('')}</ol></section>` : ''}
++<section class="tr-sec" id="tr-city-places"><div class="tr-sec-head"><h2>Places</h2><a class="tr-more" href="https://www.openstreetmap.org/?mlat=${city.lat}&mlon=${city.lon}#map=13/${city.lat}/${city.lon}" rel="noopener">OpenStreetMap</a></div>
++${places.length ? `<ol class="tr-place-list">${places.map((p) => `<li class="tr-place-row"><a href="/place/${esc(p.id)}"><strong>${esc(p.name)}</strong><span class="tr-muted">${esc(String(p.kind || '').replace(/_/g, ' '))}${p.address ? ` · ${esc(p.address)}` : ''}${p.wheelchair === 'yes' ? ' · step-free' : ''}${p.fee === 'no' ? ' · free' : ''}</span></a></li>`).join('')}</ol>` : empty('No OpenStreetMap place was stored for this city yet. The places job fills it.', '/cities', 'Another city')}
++<p class="tr-fine">${SOURCE.osm}</p></section>
++${live.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Live tours and recordings</h2><a class="tr-more" href="/live">All live</a></div><ol class="tr-live-list">${live.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.kind === 'stream' ? 'Live now' : l.kind === 'clip' ? 'Clip' : 'Recording')} · ${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.kind === 'stream' ? ` · ${Number(l.viewer_count)} watching` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Live's public stream list · fetched ${when(live[0].fetched_at)}</p></section>` : ''}
++${stays.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2><a class="tr-more" href="/stays">All stays</a></div><ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.kind)} · ${money(s0.price, s0.currency)} / ${esc(s0.period)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span></a></li>`).join('')}</ol><p class="tr-fine">OpenVibe.Rent's public listings · never a payment on Tours.</p></section>` : ''}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Plan a trip here</h2></div><p class="tr-muted">Sign in, pick dates, save places from this city and add a route and a stay.</p><p><a class="tr-btn" href="/trips/new?city=${encodeURIComponent(city.slug)}">Start a trip in ${esc(city.name)}</a> <a class="tr-btn tr-btn-quiet" href="/flights?to=${encodeURIComponent(city.name)}">Find a flight</a></p></section>
++</div>`;
++}
++
++function placePage({ place, routeList = [], trips = [] } = {}) {
++ return `<div class="tr-wrap" id="tr-place-page">
++<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(place.city_slug)}">${esc(place.city_name)}</a></nav>
++<header class="tr-page-h"><p class="tr-kicker">${esc(String(place.kind || '').replace(/_/g, ' '))}</p><h1>${esc(place.name)}</h1>
++<p class="tr-lede">${esc(place.description || `${place.name} in ${place.city_name}, from OpenStreetMap.`)}</p>
++<p class="tr-fine">${place.osm_url ? `<a href="${esc(place.osm_url)}" rel="noopener">OpenStreetMap ${esc(place.osm_type)} ${esc(place.osm_id)}</a> · ` : ''}<a href="${esc(place.source_url)}" rel="noopener">${esc(place.source === 'wikivoyage' ? 'Wikivoyage' : 'source')}</a> · fetched ${when(place.fetched_at)}${place.wikidata_id ? ` · <a href="https://www.wikidata.org/wiki/${esc(place.wikidata_id)}" rel="noopener">Wikidata</a>` : ''}</p></header>
++${safeHttp(place.image_url) ? `<figure class="tr-place-img"><img src="${esc(place.image_url)}" alt="${esc(place.name)}" loading="lazy" decoding="async"><figcaption>${esc(place.image_credit || 'OpenStreetMap contributor')}${place.image_license ? ` · ${esc(place.image_license)}` : ''}</figcaption></figure>` : ''}
++<dl class="tr-facts">
++${place.address ? `<div><dt>Address</dt><dd>${esc(place.address)}</dd></div>` : ''}
++${place.opening_hours ? `<div><dt>Opening hours</dt><dd>${esc(place.opening_hours)}</dd></div>` : ''}
++${place.wheelchair ? `<div><dt>Step-free</dt><dd>${esc(place.wheelchair)}</dd></div>` : ''}
++${place.fee ? `<div><dt>Fee</dt><dd>${esc(place.fee)}</dd></div>` : ''}
++<div><dt>Coordinates</dt><dd>${Number(place.lat).toFixed(5)}, ${Number(place.lon).toFixed(5)} · <a href="${esc(place.map_url)}" rel="noopener">map</a></dd></div>
++${safeHttp(place.website) ? `<div><dt>Website</dt><dd><a href="${esc(place.website)}" rel="nofollow noopener">${esc(place.website)}</a></dd></div>` : ''}
++</dl>
++${routeList.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>On these routes</h2></div><ol class="tr-route-list">${routeList.map((r) => `<li><a href="/routes/${esc(r.id)}"><strong>${esc(r.title)}</strong><span class="tr-muted">${esc(r.city_name)} · ${Number(r.stop_count)} stops</span></a></li>`).join('')}</ol></section>` : ''}
++${trips.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Save it to a trip</h2></div><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/places"><input type="hidden" name="place_id" value="${esc(place.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><label class="tr-field"><span>Note (optional)</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form></section>` : `<section class="tr-sec"><p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(`/place/${place.id}`)}">Sign in</a> to save this place to a trip.</p></section>`}
++<p class="tr-fine">${SOURCE.osm}</p>
++</div>`;
++}
++
++function miniMap(stops, label) {
++ const pts = (stops || []).filter((s) => Number.isFinite(Number(s.lat)) && Number.isFinite(Number(s.lon)));
++ 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 || 0.01)) * 560;
++ const y = (lat) => 20 + ((maxLat - lat) / (maxLat - minLat || 0.01)) * 200;
++ return `<figure class="tr-map-fallback" id="tr-map-fallback"><svg viewBox="0 0 600 240" role="img" aria-label="${esc(label)}">
++<rect width="600" height="240" rx="14" class="tr-map-bg"/>
++<path d="M0 80 H600 M0 160 H600 M120 0 V240 M300 0 V240 M480 0 V240" class="tr-map-grid"/>
++${pts.map((p, i) => `<g><circle cx="${x(Number(p.lon)).toFixed(1)}" cy="${y(Number(p.lat)).toFixed(1)}" r="9" class="tr-pin"/><text x="${x(Number(p.lon)).toFixed(1)}" y="${(y(Number(p.lat)) + 4).toFixed(1)}" text-anchor="middle" class="tr-pin-n">${i + 1}</text></g>`).join('')}
++</svg><figcaption>${pts.length} ${pts.length === 1 ? 'stop' : 'stops'} on the map. ${SOURCE.osm}</figcaption></figure>`;
++}
++
++function routePage({ route, trips = [] } = {}) {
++ const stops = route.stops || [];
++ const mapData = esc(JSON.stringify(stops.map((s, i) => ({ n: i + 1, title: s.title, lat: s.lat, lon: s.lon }))));
++ return `<div class="tr-wrap" id="tr-route-page">
++<nav class="crumbs" aria-label="Breadcrumbs"><a href="/cities">Cities</a> / <a href="/city/${esc(route.city_slug)}">${esc(route.city_name)}</a> / Route</nav>
++<header class="tr-page-h"><p class="tr-kicker">Self-guided route · generated from Wikivoyage</p><h1>${esc(route.title)}</h1>
++<p class="tr-lede">${esc(route.summary || '')}</p>
++<p class="tr-fine">${Number(route.stop_count)} stops · ${km(route.distance_m)} · about ${Number(route.duration_min)} min on foot · <a href="${esc(route.source_url)}" rel="noopener">Wikivoyage source</a> · fetched ${when(route.fetched_at)}. Not a guided tour: check each place before you go.</p></header>
++<div class="tr-map" id="tr-map" data-stops="${mapData}" data-label="${esc(route.title)}" data-osm-attribution="© OpenStreetMap contributors">
++${miniMap(stops, `Map of ${route.title}`)}
++<div id="tr-maplibre" class="tr-maplibre" hidden aria-label="Interactive map"></div>
++</div>
++<ol class="tr-stops">${stops.map((s) => `<li class="tr-stop" id="stop-${Number(s.position)}">
++<span class="tr-stop-num" aria-hidden="true">${Number(s.position)}</span>
++<div class="tr-stop-body"><h2>${esc(s.title)}</h2>
++${s.note ? `<p>${esc(s.note)}</p>` : ''}
++<p class="tr-fine">${esc(Number(s.lat).toFixed(5))}, ${esc(Number(s.lon).toFixed(5))} · <a href="${esc(s.map_url)}" rel="noopener">OpenStreetMap</a> · <a href="${esc(s.source_url)}" rel="noopener">Wikivoyage note</a>${s.place_id ? ` · <a href="/place/${esc(s.place_id)}">place page</a>` : ''}</p>
++<p class="tr-audio tr-muted">Audio: not available${s.audio_status === 'ready' && s.audio_url ? ` · <a href="${esc(s.audio_url)}">listen</a>` : ' — OpenVibe.AI has no speech-synthesis API today'}.</p>
++</div></li>`).join('')}</ol>
++<section class="tr-sec"><div class="tr-sec-head"><h2>Use this route</h2></div>
++${trips.length ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/route"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t) => `<option value="${esc(t.slug)}">${esc(t.title)}</option>`).join('')}</select></label><input type="hidden" name="route_id" value="${esc(route.id)}"><button class="tr-btn" type="submit">Add to trip</button></form>` : `<p class="tr-muted"><a href="/auth/login?next=${encodeURIComponent(`/routes/${route.id}`)}">Sign in</a> to add this route to a trip.</p>`}
++<p class="tr-fine">Notes and the ordering come from Wikivoyage, CC BY-SA 4.0; coordinates and map data from OpenStreetMap, ODbL. The route drawing is generated, not a guide's promise.</p></section>
++</div>`;
++}
++
++function livePage({ live = [], recent = [] } = {}) {
++ return `<div class="tr-wrap" id="tr-live-page">
++<header class="tr-page-h"><p class="tr-kicker">Watch</p><h1>Live tours now</h1>
++<p class="tr-lede">IRL streams on OpenVibe.Live whose public category or title is a real-world tour. A city is attached only when the title or tags name one; Live publishes no location. Recordings are recent IRL VODs. Tours reads the public list and never posts, follows or chats.</p></header>
++${live.length ? `<ol class="tr-live-grid">${live.map((l) => `<li><a class="tr-live-card" href="${esc(l.url)}" rel="noopener">
++${safeHttp(l.thumbnail_url) ? `<img src="${esc(l.thumbnail_url)}" alt="" loading="lazy" decoding="async">` : '<span class="tr-live-noimg" aria-hidden="true">●</span>'}
++<span class="tr-live-live">LIVE</span><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''} · ${Number(l.viewer_count)} watching</span></a></li>`).join('')}</ol>` : empty('No IRL tour is live on OpenVibe.Live right now. Recent recordings are below.', '/cities', 'Browse cities')}
++${recent.length ? `<section class="tr-sec"><div class="tr-sec-head"><h2>Recent IRL recordings and clips</h2></div><ol class="tr-live-list">${recent.map((l) => `<li><a href="${esc(l.url)}" rel="noopener"><strong>${esc(l.title)}</strong><span class="tr-muted">${esc(l.display_name || l.username)}${l.city_name ? ` · ${esc(l.city_name)}` : ''}${l.ended_at ? ` · ended ${when(l.ended_at)}` : ''}</span></a></li>`).join('')}</ol></section>` : ''}
++<p class="tr-fine">OpenVibe.Live · public stream list · fetched ${when((live[0] || recent[0] || {}).fetched_at)}</p>
++</div>`;
++}
++
++function staysPage({ stays = [], trips = [] } = {}) {
++ return `<div class="tr-wrap" id="tr-stays-page">
++<header class="tr-page-h"><p class="tr-kicker">Stay</p><h1>Stays from OpenVibe.Rent</h1>
++<p class="tr-lede">Active rentals on OpenVibe.Rent for the cities in the catalogue, with the city, the price and the period as the listing states them. Tours never handles a payment and never takes a booking fee.</p></header>
++${stays.length ? `<ol class="tr-stay-grid">${stays.map((s0) => `<li><a class="tr-stay-card" href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${esc(s0.city_name)}${s0.neighbourhood ? ` · ${esc(s0.neighbourhood)}` : ''}</span><span class="tr-price">${money(s0.price, s0.currency)} <small>/ ${esc(s0.period)}</small></span><span class="tr-muted">${esc(String(s0.kind).replace(/_/g, ' '))} · OpenVibe.Rent</span></a>${trips.length ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trips[0].slug)}/stays"><input type="hidden" name="stay_id" value="${esc(s0.id)}"><label class="tr-field"><span>Trip</span><select name="trip">${trips.map((t0) => `<option value="${esc(t0.slug)}">${esc(t0.title)}</option>`).join('')}</select></label><button class="tr-btn tr-btn-quiet" type="submit">Add to trip</button></form>` : ''}</li>`).join('')}</ol>` : empty('OpenVibe.Rent has no active listing in these cities today, so the shelf is honestly empty. It fills as people post.', '/cities', 'Browse cities')}
++<p class="tr-fine">Listings from <a href="https://openvibe.rent" rel="noopener">OpenVibe.Rent</a>'s public API, fetched ${when((stays[0] || {}).fetched_at)}. Never pay a deposit before you have seen the place and signed an agreement.</p>
++</div>`;
++}
++
++function flightsPage({ values = {}, result = null, error = null, providers = {} } = {}) {
++ const v = { from: '', to: '', depart: '', return: '', ...values };
++ return `<div class="tr-wrap tr-narrow" id="tr-flights-page">
++<header class="tr-page-h"><p class="tr-kicker">Fly</p><h1>Find a flight</h1>
++<p class="tr-lede">There is no flight API key yet, so Tours does not invent a price. This form opens the official airline-neutral search with your city pair and dates filled in, in a new tab. No affiliate link, no commission.</p></header>
++<form class="tr-form tr-card" method="get" action="/flights">
++<label class="tr-field"><span>From</span><input name="from" value="${esc(v.from)}" maxlength="80" required placeholder="Lisbon"></label>
++<label class="tr-field"><span>To</span><input name="to" value="${esc(v.to)}" maxlength="80" required placeholder="Tokyo"></label>
++<label class="tr-field"><span>Depart</span><input type="date" name="depart" value="${esc(v.depart)}" required></label>
++<label class="tr-field"><span>Return (optional)</span><input type="date" name="return" value="${esc(v.return)}"></label>
++<button class="tr-btn" type="submit">Open the official search</button>
++</form>
++${error ? note(esc(error), 'error') : ''}
++${result ? `<section class="tr-sec" id="tr-flight-result"><h2>Your search</h2><p><a class="tr-btn" href="${esc(result.url)}" rel="noopener" target="_blank">Flights ${esc(result.from)} → ${esc(result.to)}, ${esc(result.depart)}${result.return ? ` to ${esc(result.return)}` : ''}</a></p><p class="tr-fine">${esc(result.note)}</p></section>` : ''}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Providers</h2></div><ul class="tr-list"><li>Google Flights (official, airline-neutral): on, as a link only.</li><li>Duffel: ${providers.duffel ? 'a key is configured, but no adapter exists in v1, so it is off.' : 'off — no key.'}</li><li>Kiwi Tequila: ${providers.kiwi ? 'a key is configured, but no adapter exists in v1, so it is off.' : 'off — no key.'}</li></ul><p class="tr-fine">A later provider replaces the link behind the same form. Prices would then carry their source and fetch time.</p></section>
++</div>`;
++}
++
++function signIn({ title = 'Sign in', text = 'This page is for a signed-in person.', next = '/' } = {}) {
++ return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-sign-in"><header class="tr-page-h"><h1>${esc(title)}</h1><p class="tr-lede">${esc(text)}</p></header><p><a class="tr-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></p></div>`;
++}
++
++function tripNew({ cities = [], values = {}, error = null } = {}) {
++ const v = { title: '', city: '', starts_on: '', ends_on: '', notes: '', ...values };
++ return `<div class="tr-wrap tr-narrow" id="tr-trip-new">
++<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Plan a trip</h1><p class="tr-lede">A city, dates and a name. After this you can save places, add a self-guided route and pick a stay; share it read-only when it is ready.</p></header>
++${error ? note(esc(error), 'error') : ''}
++<form class="tr-form tr-card" method="post" action="/trips">
++<label class="tr-field"><span>Trip name</span><input name="title" value="${esc(v.title)}" maxlength="120" required placeholder="Lisbon in November"></label>
++<label class="tr-field"><span>City</span><select name="city" required><option value="">Choose a city</option>${cities.map((c) => `<option value="${esc(c.slug)}"${c.slug === v.city ? ' selected' : ''}>${esc(c.name)}, ${esc(c.country)}</option>`).join('')}</select></label>
++<label class="tr-field"><span>Starts</span><input type="date" name="starts_on" value="${esc(v.starts_on)}" required></label>
++<label class="tr-field"><span>Ends</span><input type="date" name="ends_on" value="${esc(v.ends_on)}" required></label>
++<label class="tr-field"><span>Notes (optional)</span><textarea name="notes" rows="4" maxlength="2000">${esc(v.notes)}</textarea></label>
++<button class="tr-btn" type="submit">Create the trip</button>
++</form></div>`;
++}
++
++function tripsPage({ trips = [] } = {}) {
++ return `<div class="tr-wrap" id="tr-trips-page">
++<header class="tr-page-h"><p class="tr-kicker">Trips</p><h1>Your trips</h1><p class="tr-lede">Private by default. A shared trip has a read-only link; only you can edit it. The API and forms never expose another person's trip.</p><p><a class="tr-btn" href="/trips/new">Plan a new trip</a></p></header>
++${trips.length ? `<ol class="tr-trip-list">${trips.map((t) => `<li><a href="/trips/${esc(t.slug)}"><strong>${esc(t.title)}</strong><span class="tr-muted">${esc(t.city_name || t.city_slug || '')} · ${day(t.starts_on)} to ${day(t.ends_on)} · ${esc(t.visibility)}</span></a></li>`).join('')}</ol>` : empty('No trip yet. Start with a city and two dates.', '/trips/new', 'Plan a trip')}
++</div>`;
++}
++
++function tripPage({ trip, mine = false, cities = [], places = [], route = null, stays = [] } = {}) {
++ const shareUrl = `/trips/${trip.slug}`;
++ return `<div class="tr-wrap" id="tr-trip-page">
++<nav class="crumbs" aria-label="Breadcrumbs"><a href="/trips">Trips</a> / ${esc(trip.title)}</nav>
++<header class="tr-page-h"><p class="tr-kicker">${esc(trip.city_name || '')} · ${day(trip.starts_on)} to ${day(trip.ends_on)}</p><h1>${esc(trip.title)}</h1>
++${trip.notes ? `<p class="tr-lede">${esc(trip.notes)}</p>` : ''}
++<p class="tr-fine">${esc(trip.visibility === 'shared' ? 'Shared read-only' : 'Private')}${mine ? '' : ' · you are reading a shared trip'} · last changed ${when(trip.updated_at)}</p></header>
++${mine ? `<section class="tr-sec tr-card"><h2>Share and edit</h2><p class="tr-muted">${trip.visibility === 'shared' ? 'Anyone with this link can read it.' : 'Only you can read it.'}</p><p><code>${esc(shareUrl)}</code></p><form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/share"><input type="hidden" name="shared" value="${trip.visibility === 'shared' ? '0' : '1'}"><button class="tr-btn tr-btn-quiet" type="submit">${trip.visibility === 'shared' ? 'Make it private' : 'Share read-only'}</button></form>
++<details class="tr-edit"><summary>Edit the name, city and dates</summary><form class="tr-form" method="post" action="/trips/${esc(trip.slug)}/edit">
++<label class="tr-field"><span>Trip name</span><input name="title" value="${esc(trip.title)}" maxlength="120" required></label>
++<label class="tr-field"><span>City</span><select name="city">${cities.map((c) => `<option value="${esc(c.slug)}"${c.id === trip.city_id ? ' selected' : ''}>${esc(c.name)}, ${esc(c.country)}</option>`).join('')}</select></label>
++<label class="tr-field"><span>Starts</span><input type="date" name="starts_on" value="${esc(day(trip.starts_on))}" required></label>
++<label class="tr-field"><span>Ends</span><input type="date" name="ends_on" value="${esc(day(trip.ends_on))}" required></label>
++<label class="tr-field"><span>Notes</span><textarea name="notes" rows="3" maxlength="2000">${esc(trip.notes || '')}</textarea></label>
++<button class="tr-btn" type="submit">Save the trip</button></form></details></section>` : ''}
++<section class="tr-sec"><div class="tr-sec-head"><h2>Saved places</h2>${mine ? '' : `<span class="tr-muted">${places.length} places</span>`}</div>
++${places.length ? `<ol class="tr-trip-places">${places.map((p) => `<li><span class="tr-stop-num" aria-hidden="true">${Number(p.position)}</span><div><a href="/place/${esc(p.id)}"><strong>${esc(p.name)}</strong></a><span class="tr-muted">${esc(p.city_name)}${p.trip_note ? ` · ${esc(p.trip_note)}` : ''}</span>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/places/${esc(p.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</div></li>`).join('')}</ol>` : empty('No place saved yet. Open a city and a place, then save it here.', trip.city_slug ? `/city/${trip.city_slug}` : '/cities', 'Find places')}
++${mine ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/places"><label class="tr-field"><span>Place id</span><input name="place_id" required placeholder="plc_…" pattern="plc_[0-9A-HJKMNP-TV-Z]{26}"></label><label class="tr-field"><span>Note</span><input name="note" maxlength="500"></label><button class="tr-btn" type="submit">Save place</button></form><p class="tr-fine">The place id is on the place page. In the API: POST /api/v1/trips/${esc(trip.slug)}/places.</p>` : ''}
++</section>
++<section class="tr-sec"><div class="tr-sec-head"><h2>Route</h2>${mine ? `<a class="tr-more" href="${trip.city_slug ? `/city/${trip.city_slug}` : '/routes'}">Find a route</a>` : ''}</div>
++${route ? `<p><a href="/routes/${esc(route.id)}"><strong>${esc(route.title)}</strong></a> <span class="tr-muted">${Number(route.stop_count)} stops · ${km(route.distance_m)}</span></p>${mine ? `<form method="post" action="/trips/${esc(trip.slug)}/route"><input type="hidden" name="route_id" value=""><button class="tr-btn tr-btn-quiet" type="submit">Remove route</button></form>` : ''}` : `<p class="tr-muted">No route added${mine ? '. Open a route and press “Add to trip”.' : '.'}</p>`}
++</section>
++<section class="tr-sec"><div class="tr-sec-head"><h2>Stays</h2>${mine ? `<a class="tr-more" href="/stays">Find a stay</a>` : ''}</div>
++${stays.length ? `<ol class="tr-stay-list">${stays.map((s0) => `<li><a href="${esc(s0.url)}" rel="noopener"><strong>${esc(s0.title)}</strong><span class="tr-muted">${money(s0.price, s0.currency)} / ${esc(s0.period)}</span></a>${mine ? `<form class="tr-inline-form" method="post" action="/trips/${esc(trip.slug)}/stays/${esc(s0.id)}/remove"><button class="tr-link-btn" type="submit">Remove</button></form>` : ''}</li>`).join('')}</ol>` : `<p class="tr-muted">No stay added${mine ? '. Open a stay and add it here.' : '.'}</p>`}
++${mine ? `<form class="tr-form tr-form-inline" method="post" action="/trips/${esc(trip.slug)}/stays"><label class="tr-field"><span>Stay id</span><input name="stay_id" required placeholder="sty_…" pattern="sty_[0-9A-HJKMNP-TV-Z]{26}"></label><button class="tr-btn" type="submit">Add stay</button></form>` : ''}
++</section>
++${mine ? `<section class="tr-sec tr-card"><h2>Delete this trip</h2><form method="post" action="/trips/${esc(trip.slug)}/delete"><button class="tr-btn tr-btn-quiet" type="submit">Delete the trip</button></form><p class="tr-fine">This erases the trip and its saved places, route and stays. Places and routes themselves are public data and stay.</p></section>` : ''}
++</div>`;
++}
++
++function developersPage({ jobs = [] } = {}) {
++ const routes = [
++ ['GET /api/v1/cities', 'the catalogue, newest population first', '?limit=&offset=&q='],
++ ['GET /api/v1/cities/:slug', 'one city, its places and routes', '?slug=lisbon'],
++ ['GET /api/v1/places/:id', 'one OpenStreetMap place with source and fetch time', ''],
++ ['GET /api/v1/routes/:id', 'one route with its ordered, cited stops', ''],
++ ['GET /api/v1/live', 'IRL tours now and recent IRL VODs', ''],
++ ['GET /api/v1/trips', 'the signed-in person\'s trips', ''],
++ ['POST /api/v1/trips', 'create a trip { title, city_slug, starts_on, ends_on, notes? }', ''],
++ ['GET /api/v1/trips/:slug', 'your trip, or a shared one read-only', ''],
++ ['PATCH /api/v1/trips/:slug', 'edit title, city, dates, notes or visibility', ''],
++ ['DELETE /api/v1/trips/:slug', 'delete your trip', ''],
++ ['POST /api/v1/trips/:slug/places', 'save a place { place_id, note? }', ''],
++ ['POST /api/v1/trips/:slug/route', 'set the trip\'s route { route_id }', ''],
++ ['POST /api/v1/trips/:slug/stays', 'add a stay { stay_id }', ''],
++ ['GET /api/v1/openapi.json', 'the OpenAPI 3.1 document', ''],
++ ];
++ return `<div class="tr-wrap tr-narrow" id="tr-developers-page">
++<header class="tr-page-h"><p class="tr-kicker">For developers</p><h1>The OpenVibe.Tours API</h1>
++<p class="tr-lede">Reads are public. A trip write is a signed-in Network person (the tours_at cookie or a Bearer user token); a session write must come from openvibe.tours itself. No route needs a service token. Errors are application/problem+json with a stable code.</p></header>
++<div class="tr-table-wrap"><table class="tr-table"><thead><tr><th scope="col">Route</th><th scope="col">What it does</th><th scope="col">Query or body</th></tr></thead><tbody>${routes.map(([r, what, q]) => `<tr><td><code>${esc(r)}</code></td><td>${esc(what)}</td><td>${q ? `<code>${esc(q)}</code>` : '—'}</td></tr>`).join('')}</tbody></table></div>
++<section class="tr-sec"><div class="tr-sec-head"><h2>OpenAPI</h2></div><p><a href="/api/v1/openapi.json">/api/v1/openapi.json</a> · <a href="/llms.txt">llms.txt</a> · <a href="/sitemap.xml">sitemap.xml</a></p></section>
++<section class="tr-sec"><div class="tr-sec-head"><h2>Sources and licences</h2></div><ul class="tr-list"><li>OpenStreetMap via Overpass, ODbL 1.0; every place links its OSM object.</li><li>Wikivoyage, CC BY-SA 4.0; every route stop links the page it came from.</li><li>Wikidata, CC0; cities keep their Q id and every image keeps its Commons licence.</li><li>OpenVibe.Live and OpenVibe.Rent public APIs; read-only, never a token.</li></ul></section>
++<section class="tr-sec"><div class="tr-sec-head"><h2>Source jobs</h2></div><p class="tr-muted">Each job records its run in tours_job_runs. Off under TOURS_DRILL; the intervals are env names in the README.</p><ul class="tr-jobs">${jobs.map((j) => `<li><b>${esc(j.job)}</b> ${esc(stateLabel(j.status))}${j.finished_at ? ` · ${when(j.finished_at)}` : ''}</li>`).join('') || '<li>No job has run yet.</li>'}</ul></section>
++</div>`;
++}
++
++function notFound() {
++ return `<div class="tr-wrap tr-narrow tr-page-pad" id="tr-not-found"><p class="tr-kicker">404</p><h1>Not here</h1><p class="tr-lede">That city, place, route or trip does not exist, or it is a private trip.</p><p><a class="tr-btn" href="/">Go home</a> <a class="tr-btn tr-btn-quiet" href="/cities">Browse cities</a></p></div>`;
++}
++
++module.exports = { home, citiesPage, cityPage, placePage, routePage, livePage, staysPage, flightsPage, signIn, tripNew, tripsPage, tripPage, developersPage, notFound, placeOfDayCard, heroMotif, miniMap, SOURCE };
ddiff --git a/server/views/labels.js b/server/views/labels.js
nnew file mode 100644
iindex 0000000..b090182
---- /dev/null
++++ b/server/views/labels.js
@@@ -0,0 +1,13 @@
++'use strict';
++
++/** The small words the pages share: job status and a few labels. */
++const JOB_STATUS = {
++ running: 'running',
++ ok: 'ok',
++ partial: 'partly fetched',
++ failed: 'failed',
++};
++
++const stateLabel = (s) => JOB_STATUS[s] || String(s || 'unknown');
++
++module.exports = { stateLabel, JOB_STATUS };
ddiff --git a/test/accent.test.js b/test/accent.test.js
nnew file mode 100644
iindex 0000000..dbc0af0
---- /dev/null
++++ b/test/accent.test.js
@@@ -0,0 +1,27 @@
++'use strict';
++/**
++ * The room's accent passes WCAG AA for text in both themes: the dark accent against the dark canvas and the light
++ * accent against white, at 4.5:1 or more. rooms.js is the one table every site reads.
++ */
++const assert = require('assert');
++const rooms = require('openvibe-shared/rooms');
++
++function luminance(hex) {
++ const h = String(hex).replace('#', '');
++ const rgb = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
++ const lin = rgb.map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
++ return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
++}
++function contrast(a, b) {
++ const l1 = luminance(a); const l2 = luminance(b);
++ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
++ return (hi + 0.05) / (lo + 0.05);
++}
++
++const room = rooms.get('tours');
++assert.ok(room, 'rooms.js has the tours room');
++assert.strictEqual(room.host, 'openvibe.tours');
++assert.ok(contrast(room.dark, '#0B0D12') >= 4.5, `dark accent ${room.dark} on #0B0D12 = ${contrast(room.dark, '#0B0D12').toFixed(2)}:1`);
++assert.ok(contrast(room.light, '#FFFFFF') >= 4.5, `light accent ${room.light} on white = ${contrast(room.light, '#FFFFFF').toFixed(2)}:1`);
++assert.match(rooms.style('tours'), /--room-dark:#F98942;--room-light:#AA3900/);
++process.stdout.write(` ✓ tours accent ${room.dark} on #0B0D12 (${contrast(room.dark, '#0B0D12').toFixed(2)}:1) and ${room.light} on white (${contrast(room.light, '#FFFFFF').toFixed(2)}:1)\n\nall passed\n`);
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..61d5848
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++/**
++ * ADR-033: Tours' part of an account export and deletion, applied through /internal/events with a stand-in Network.
++ * A person's trips are exported; on deletion they are erased (saved places, route and stays cascade) and another
++ * person's trips 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_tours', 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: 'tours', clientSecret: 'tours-secret' });
++ const t = await boot({ env: { TOURS_EVENTS_SECRET: SECRET }, accountSend: sender });
++ const db = t.ctx.s.db;
++ try {
++ const city = await db.maybe("SELECT id FROM tours_cities LIMIT 1");
++ const tripA = `trp_${ids.ulid()}`;
++ const tripB = `trp_${ids.ulid()}`;
++ await db.exec(`INSERT INTO tours_trips (id, slug, owner_subject, title, city_id, starts_on, ends_on)
++ VALUES ($1, 'trip-aaaaaaaaaaaaaaaaaaaaaaaa', $2, 'Kim in Lisbon', $3, '2026-11-10', '2026-11-12'),
++ ($4, 'trip-bbbbbbbbbbbbbbbbbbbbbbbb', $5, 'Sam in Lisbon', $3, '2026-12-01', '2026-12-03')`,
++ [tripA, A, city ? city.id : null, tripB, B]);
++
++ await check('an export carries only A\'s trips', async () => {
++ const body = bodyOf(exportEvent);
++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, 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_tours');
++ assert.strictEqual(part.body.subject, A);
++ const trips = part.body.files.find((f) => f.name === 'trips.json');
++ assert.ok(trips, part.body.files.map((f) => f.name).join(','));
++ assert.match(JSON.stringify(trips), /Kim in Lisbon/);
++ assert.ok(!JSON.stringify(part.body).includes('Sam in Lisbon'), 'nobody else\'s trip');
++ });
++
++ await check('a deletion erases A\'s trips and leaves B alone', async () => {
++ const body = bodyOf(deleteEvent);
++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ assert.strictEqual(res.status, 200, res.text);
++ assert.strictEqual(res.json().outcome, 'erased');
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int FROM tours_trips WHERE owner_subject = $1', [A])), 0);
++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int FROM tours_trips WHERE owner_subject = $1', [B])), 1);
++ 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.tours_trips) >= 1);
++ });
++
++ await check('a redelivery changes nothing; a bad signature is 401 and a forwarded request is 403', async () => {
++ const body = bodyOf(deleteEvent);
++ const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
++ assert.strictEqual(again.status, 200);
++ assert.strictEqual(again.json().outcome, 'unchanged');
++ const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, WRONG) } });
++ assert.strictEqual(bad.status, 401);
++ const forwarded = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(body, SECRET) } });
++ assert.strictEqual(forwarded.status, 403);
++ });
++ } finally {
++ await t.close();
++ await stub.close();
++ }
++ done();
++})();
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..d28c396
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * The JSON API's public reads stay short and cacheable, its refusals are application/problem+json, and every route
++ * the brief names exists. A trip write is a person; an app token is refused.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++
++(async () => {
++ const t = await boot();
++ try {
++ await seed(t);
++
++ await check('GET /api/v1/cities: the catalogue, a q filter, and a bounded limit', async () => {
++ const all = await t.get('/api/v1/cities');
++ assert.strictEqual(all.status, 200);
++ assert.strictEqual(all.json().count, 6);
++ assert.match(all.headers.get('cache-control'), /public, max-age=60/);
++ const lisbon = await t.get('/api/v1/cities?q=lis');
++ assert.strictEqual(lisbon.json().count, 1);
++ assert.strictEqual(lisbon.json().cities[0].slug, 'lisbon');
++ for (const p of ['/api/v1/cities?limit=0', '/api/v1/cities?limit=201', '/api/v1/cities?offset=-1']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 400, p);
++ assert.strictEqual(r.json().code, 'tours.invalid');
++ }
++ });
++
++ await check('GET /api/v1/cities/:slug: one city with places, routes, live and stays; 404 otherwise', async () => {
++ const r = await t.get('/api/v1/cities/lisbon');
++ assert.strictEqual(r.status, 200);
++ const b = r.json();
++ assert.strictEqual(b.city.name, 'Lisbon');
++ assert.ok(b.places.length >= 3);
++ assert.ok(b.routes.length >= 1);
++ assert.strictEqual(b.live[0].city_name, 'Lisbon');
++ assert.strictEqual(b.stays[0].currency, 'EUR');
++ assert.ok(b.city.source_url.startsWith('https://en.wikivoyage.org/'));
++ assert.strictEqual((await t.get('/api/v1/cities/nowhere')).status, 404);
++ });
++
++ await check('GET /api/v1/places/:id and /routes/:id carry the source and the ordered stops', async () => {
++ const place = await t.ctx.s.db.maybe("SELECT id FROM tours_places WHERE source = 'openstreetmap' LIMIT 1");
++ const p = await t.get(`/api/v1/places/${place.id}`);
++ assert.strictEqual(p.status, 200);
++ assert.match(p.json().place.osm_url, /^https:\/\/www\.openstreetmap\.org\//);
++ assert.ok(p.json().place.fetched_at);
++ const route = await t.ctx.s.db.maybe('SELECT id FROM tours_routes WHERE slug = $1', ['tokyo-see-1']);
++ const r = await t.get(`/api/v1/routes/${route.id}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json().route.stops.length, 6);
++ assert.deepStrictEqual(r.json().route.stops.map((s) => s.position), [1, 2, 3, 4, 5, 6]);
++ assert.ok(r.json().route.stops.every((s) => s.audio_status === 'none' && /wikivoyage\.org/.test(s.source_url)));
++ assert.strictEqual((await t.get('/api/v1/places/plc_00000000000000000000000000')).status, 404);
++ });
++
++ await check('GET /api/v1/live: streams now and recent VODs', async () => {
++ const r = await t.get('/api/v1/live');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.json().live.length, 1);
++ assert.strictEqual(r.json().recent.length, 2);
++ assert.match(r.json().source, /OpenVibe\.Live/);
++ assert.strictEqual(r.json().live[0].url, 'https://openvibe.live/@ada');
++ });
++
++ await check('GET /api/v1/openapi.json is OpenAPI 3.1 with every brief route', async () => {
++ const r = await t.get('/api/v1/openapi.json');
++ assert.strictEqual(r.status, 200);
++ const doc = r.json();
++ assert.strictEqual(doc.openapi, '3.1.0');
++ for (const p of ['/api/v1/cities', '/api/v1/cities/{slug}', '/api/v1/places/{id}', '/api/v1/routes/{id}', '/api/v1/live', '/api/v1/trips', '/api/v1/trips/{slug}']) {
++ assert.ok(doc.paths[p], p);
++ }
++ assert.ok(doc.components.schemas.City && doc.components.schemas.Place && doc.components.schemas.Route);
++ });
++
++ await check('malformed JSON and unknown routes are problem+json with stable codes', async () => {
++ const bad = await t.get('/api/v1/trips', { 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('an anonymous trip write is 401; an app token is 403, never silently a person', async () => {
++ const anon = await t.get('/api/v1/trips', { json: { title: 'No', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-11' } });
++ assert.strictEqual(anon.status, 401);
++ assert.strictEqual(anon.json().code, 'tours.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.tours'], 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/trips', { bearer: app, json: { title: 'No', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-11' } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.json().code, 'tours.person_required');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/flights.test.js b/test/flights.test.js
nnew file mode 100644
iindex 0000000..cc4d6d0
---- /dev/null
++++ b/test/flights.test.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * Flights have no provider key in v1: the form and the API build the airline-neutral official search link with the
++ * city pair filled in, with no affiliate parameter. A Duffel/Kiwi key alone does not turn an adapter on.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { validate, searchUrl, createFlights } = require('../server/flights');
++const configLib = require('../server/config');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('validate accepts a city pair and dates, and refuses a missing city or a backward return', () => {
++ assert.strictEqual(validate({ from: 'Lisbon', to: 'Tokyo', depart: '2026-11-10' }).ok, true);
++ assert.strictEqual(validate({ from: 'Lisbon', to: 'Tokyo', depart: '2026-11-10', return: '2026-11-20' }).ok, true);
++ assert.strictEqual(validate({ from: 'L', to: 'Tokyo', depart: '2026-11-10' }).ok, false);
++ assert.strictEqual(validate({ from: 'Lisbon', to: 'Tokyo', depart: '2026-11-10', return: '2026-11-01' }).ok, false);
++ assert.strictEqual(validate({ from: 'Lisbon', to: 'Tokyo', depart: 'tomorrow' }).ok, false);
++ });
++
++ await check('the search URL is the official neutral one with the pair and dates, and no affiliate', () => {
++ const url = searchUrl({ from: 'Lisbon', to: 'Tokyo', depart: '2026-11-10', return: '2026-11-20' });
++ assert.match(url, /^https:\/\/www\.google\.com\/travel\/flights\?/);
++ const parsed = new URL(url);
++ assert.match(parsed.searchParams.get('q'), /Flights from Lisbon to Tokyo/);
++ assert.match(parsed.searchParams.get('q'), /2026-11-10/);
++ for (const bad of ['tag=', 'partner=', 'ref=', 'utm_']) assert.ok(!url.toLowerCase().includes(bad), bad);
++ });
++
++ await check('a key alone leaves the provider seam off; the form shows the plain note', async () => {
++ const withKey = createFlights({ config: configLib.load({ TOURS_DUFFEL_API_KEY: 'not-a-real-key' }) });
++ const plan = withKey.plan({ from: 'Lisbon', to: 'Tokyo', depart: '2026-11-10' });
++ assert.strictEqual(plan.enabled, false);
++ assert.strictEqual(plan.providers.duffel, true);
++ assert.match(plan.note, /no commission/);
++ const page = await t.get('/flights?from=Lisbon&to=Tokyo&depart=2026-11-10');
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /No flight API key yet/);
++ assert.match(page.text, /official airline-neutral search/);
++ assert.match(page.text, /target="_blank"/);
++ assert.ok(!/tag=|utm_/i.test(page.text));
++ const bad = await t.get('/flights?from=Lisbon&to=Tokyo&depart=soon');
++ assert.strictEqual(bad.status, 200);
++ assert.match(bad.text, /depart must be a date/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..f236503
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,103 @@
++'use strict';
++// Many requests per test file: the per-address limits would answer 429 part-way.
++process.env.TOURS_RATE_LIMIT_PER_MIN = process.env.TOURS_RATE_LIMIT_PER_MIN || '100000';
++process.env.TOURS_API_RATE_LIMIT_PER_MIN = process.env.TOURS_API_RATE_LIMIT_PER_MIN || '100000';
++process.env.TOURS_LIMITS_MINUTE = process.env.TOURS_LIMITS_MINUTE || '100000';
++process.env.TOURS_LIMITS_HOUR = process.env.TOURS_LIMITS_HOUR || '100000';
++
++/**
++ * Boots OpenVibe.Tours on a temp database with a mock Network (test/helpers/mocks.js) and the open-data fixtures
++ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
++ * t.signIn(user) → cookie header value (tours_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.tours', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'tours', OV_OAUTH_CLIENT_SECRET: 'tours-secret', COOKIE_SECURE: 'false',
++ TOURS_DRILL: '1', TOURS_EVENTS_URL: network.url,
++ OV_LIVE_URL: upstreams.url, OV_RENT_URL: upstreams.url,
++ OV_WIKIVOYAGE_URL: upstreams.url, OV_WIKIDATA_URL: upstreams.url, OV_WIKIPEDIA_URL: upstreams.url,
++ OV_COMMONS_URL: upstreams.url, OV_OVERPASS_URL: `${upstreams.url}/interpreter`,
++ TOURS_MIN_INTERVAL_MS: '0', TOURS_CITY_LIMIT: '3', TOURS_OSM_RADIUS_M: '3000',
++ ...(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) => `tours_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;
++ if (o['sec-fetch-site']) headers['sec-fetch-site'] = o['sec-fetch-site'];
++ 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.jobs.stop();
++ await new Promise((r) => server.close(r));
++ built.ctx.keys.client.stop();
++ await built.ctx.telemetry.stop().catch(() => {});
++ await testdb.close();
++ await network.close();
++ await upstreams.close();
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..01af65a
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * TOURS_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.TOURS_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'tours', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..33d3baf
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,145 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `fun`, refresh_token,
++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Fun, kept for parity);
++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
++ *
++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(handler) {
++ return new Promise((resolve) => {
++ const sockets = new Set();
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const raw = Buffer.concat(chunks);
++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
++ });
++ });
++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
++ server.listen(0, '127.0.0.1', () => resolve({
++ server, url: `http://127.0.0.1:${server.address().port}`,
++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
++ }));
++ });
++}
++
++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++function signJwt(claims, privateKey) {
++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], coinCredits: [], down: false };
++ let issuer = null;
++ let nextUserId = 1;
++
++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
++
++ function addUser(username, { role = 'user' } = {}) {
++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
++ st.users.set(u.id, u); st.byUsername.set(username, u);
++ return u;
++ }
++ function userToken(u, { ttl = 3600, iat = null } = {}) {
++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
++ }
++ function userFrom(req) {
++ const h = String(req.headers.authorization || '');
++ if (!h.startsWith('Bearer ')) return null;
++ const parts = h.slice(7).split('.');
++ if (parts.length !== 3) return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
++ if (c.exp * 1000 < Date.now()) return 'expired';
++ return st.users.get(c.sub) || null;
++ }
++
++ const srv = await listen(async (req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
++
++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
++ if (url.pathname === '/oauth/revoke') return json(200, {});
++ if (url.pathname === '/oauth/token' && req.method === 'POST') {
++ const ct = String(req.headers['content-type'] || '');
++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
++ if (body.client_id !== 'tours' || body.client_secret !== 'tours-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:tours', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
++ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),
++ });
++ }
++ return json(400, { error: 'unsupported_grant_type' });
++ }
++
++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ return json(200, { items: [], next_cursor: null });
++ }
++
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ return json(200, { subject_id: url.searchParams.get('subject_id'), network_user_id: 42 });
++ }
++ if (url.pathname === '/internal/coins/credit' && req.method === 'POST') {
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ st.coinCredits.push(body);
++ return json(200, { balance: 25 });
++ }
++
++ return json(404, { error: 'not found' });
++ });
++ issuer = srv.url;
++
++ return {
++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests, coinCredits: st.coinCredits,
++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
++ setDown(v) { st.down = v; },
++ close: srv.close,
++ };
++}
++
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/helpers/seed.js b/test/helpers/seed.js
nnew file mode 100644
iindex 0000000..3ffdc5d
---- /dev/null
++++ b/test/helpers/seed.js
@@@ -0,0 +1,8 @@
++'use strict';
++/** Run every source job against the fixtures, in order. Returns the last receipts. */
++async function seed(t, { limit = 100 } = {}) {
++ const out = {};
++ for (const name of ['cities', 'places', 'routes', 'live', 'stays']) out[name] = await t.ctx.jobs.runOnce(name, { limit });
++ return out;
++}
++module.exports = { seed };
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
nnew file mode 100644
iindex 0000000..1cce6b4
---- /dev/null
++++ b/test/helpers/upstreams.js
@@@ -0,0 +1,230 @@
++'use strict';
++
++/**
++ * In-process stand-ins for the open-data sources and the two network services Tours reads:
++ * /w/api.php action=parse Wikivoyage
++ * /w/api.php action=query&prop=pageprops Wikipedia
++ * /w/api.php action=wbgetentities Wikidata
++ * /w/api.php action=query&prop=imageinfo Commons
++ * /interpreter Overpass
++ * /api/streams, /api/streams/recent OpenVibe.Live
++ * /api/v1/listings OpenVibe.Rent
++ *
++ * The fixtures are small and deterministic; a test changes state.pages/state.streams/state.listings and reads
++ * state.requests (headers included) to check the User-Agent, the query and the fetch time.
++ */
++const http = require('http');
++
++const WIKIVOYAGE = {
++ Tokyo: `'''Tokyo''' is the capital of Japan and one of the world's great cities. It mixes quiet shrines with dense neighbourhoods, and the trains go everywhere.
++
++==See==
++{{see | name=Senso-ji | lat=35.7148 | long=139.7967 | content=A temple in Asakusa, busy at every hour.}}
++{{see | name=Meiji Shrine | lat=35.6764 | long=139.6993 | content=A Shinto shrine in a forest beside Harajuku.}}
++{{see | name=Tokyo Skytree | lat=35.7101 | long=139.8107 | content=A tall tower with a view over the city.}}
++{{see | name=Ueno Park | lat=35.7156 | long=139.7745 | content=Museums, a pond and cherry trees.}}
++{{see | name=Tsukiji Outer Market | lat=35.6655 | long=139.7708 | content=Street food and knives.}}
++{{see | name=Shinjuku Gyoen | lat=35.6852 | long=139.7101 | content=A garden for a slow afternoon.}}
++`,
++ Delhi: `'''Delhi''' is India's capital, a city of old lanes, Mughal monuments and wide new avenues.
++
++==See==
++{{see | name=Red Fort | lat=28.6562 | long=77.2410 | content=The Mughal fort in Old Delhi.}}
++{{see | name=Humayun's Tomb | lat=28.5933 | long=77.2507 | content=A garden tomb that inspired the Taj Mahal.}}
++{{see | name=Qutub Minar | lat=28.5245 | long=77.1855 | content=A minaret and a complex of ruins.}}
++{{see | name=India Gate | lat=28.6129 | long=77.2295 | content=The war memorial at the end of Rajpath.}}
++{{see | name=Lotus Temple | lat=28.5535 | long=77.2588 | content=A Bahá'í house of worship.}}
++{{see | name=Chandni Chowk | lat=28.6507 | long=77.2300 | content=The old market lanes.}}
++`,
++ Shanghai: `'''Shanghai''' is China's largest city, with a riverfront of colonial banks and a skyline of towers.
++
++==See==
++{{see | name=The Bund | lat=31.2400 | long=121.4900 | content=The riverfront walk and its old banks.}}
++{{see | name=Yu Garden | lat=31.2272 | long=121.4921 | content=A Ming garden in the old city.}}
++{{see | name=Shanghai Museum | lat=31.2303 | long=121.4706 | content=Bronzes, ceramics and calligraphy.}}
++{{see | name=Jing'an Temple | lat=31.2231 | long=121.4453 | content=A Buddhist temple among the towers.}}
++{{see | name=Tianzifang | lat=31.2088 | long=121.4669 | content=Lanes of studios and small shops.}}
++{{see | name=Longhua Temple | lat=31.1786 | long=121.4453 | content=The oldest temple in the city.}}
++`,
++ Lisbon: `'''Lisbon''' is Portugal's capital, built on seven hills above the River Tagus. Trams climb between viewpoints, tiled façades and old neighbourhoods.
++
++==See==
++{{SeeDistricts}}
++===[[Lisbon/Alfama|Alfama]]===
++The oldest quarter, climbed by tram and on foot.
++`,
++ 'Lisbon/Alfama': `'''Alfama''' is Lisbon's oldest quarter.
++
++==See==
++{{see | name=Castelo de São Jorge | lat=38.7139 | long=-9.1335 | content=A castle above the city with a wide view.}}
++{{see | name=Lisbon Cathedral | lat=38.7098 | long=-9.1330 | content=The Romanesque cathedral.}}
++{{see | name=Miradouro de Santa Luzia | lat=38.7118 | long=-9.1300 | content=A viewpoint with azulejos and a garden.}}
++{{see | name=Miradouro da Senhora do Monte | lat=38.7190 | long=-9.1328 | content=The highest viewpoint in the old city.}}
++{{see | name=Museu do Teatro Romano | lat=38.7105 | long=-9.1324 | content=The remains of a Roman theatre.}}
++{{see | name=Panteão Nacional | lat=38.7150 | long=-9.1250 | content=The domed national pantheon.}}
++`,
++ Paris: `'''Paris''' is the capital of France, a walkable city of boulevards, museums and riverbanks.
++
++==See==
++{{see | name=Eiffel Tower | lat=48.8584 | long=2.2945 | content=The tower on the Champ de Mars.}}
++{{see | name=Louvre Museum | lat=48.8606 | long=2.3376 | content=The museum in the old palace.}}
++{{see | name=Notre-Dame | lat=48.8530 | long=2.3499 | content=The cathedral on the Île de la Cité.}}
++{{see | name=Montmartre | lat=48.8867 | long=2.3431 | content=The hill and the basilica.}}
++{{see | name=Jardin du Luxembourg | lat=48.8462 | long=2.3371 | content=A garden for an afternoon.}}
++{{see | name=Musée d'Orsay | lat=48.8600 | long=2.3266 | content=Impressionists in a former station.}}
++`,
++ Barcelona: `'''Barcelona''' is a Mediterranean city of Gothic lanes and modernista buildings.
++
++==See==
++{{see | name=Sagrada Família | lat=41.4036 | long=2.1744 | content=Gaudí's unfinished basilica.}}
++{{see | name=Park Güell | lat=41.4145 | long=2.1527 | content=Mosaics and terraces above the city.}}
++{{see | name=Gothic Quarter | lat=41.3839 | long=2.1762 | content=The old streets and the cathedral.}}
++{{see | name=Casa Batlló | lat=41.3916 | long=2.1649 | content=A modernista house on Passeig de Gràcia.}}
++{{see | name=La Boqueria | lat=41.3817 | long=2.1717 | content=The market off La Rambla.}}
++{{see | name=Barceloneta | lat=41.3784 | long=2.1925 | content=The beach and the seafood.}}
++`,
++};
++
++const ENTITIES = {
++ Q1490: { label: 'Tokyo', lat: 35.6762, lon: 139.6503, population: 13960000, image: 'File:Tokyo test.jpg', country: 'Q17', sitelink: 'Tokyo' },
++ Q1353: { label: 'Delhi', lat: 28.6139, lon: 77.2090, population: 16787941, image: 'File:Delhi test.jpg', country: 'Q668', sitelink: 'Delhi' },
++ Q8686: { label: 'Shanghai', lat: 31.2304, lon: 121.4737, population: 24870895, image: 'File:Shanghai test.jpg', country: 'Q148', sitelink: 'Shanghai' },
++ Q597: { label: 'Lisbon', lat: 38.7223, lon: -9.1393, population: 545923, image: 'File:Lisbon test.jpg', country: 'Q45', sitelink: 'Lisbon' },
++ Q90: { label: 'Paris', lat: 48.8566, lon: 2.3522, population: 2140526, image: 'File:Paris test.jpg', country: 'Q142', sitelink: 'Paris' },
++ Q1492: { label: 'Barcelona', lat: 41.3874, lon: 2.1686, population: 1620343, image: 'File:Barcelona test.jpg', country: 'Q29', sitelink: 'Barcelona' },
++};
++const COUNTRIES = { Q17: { label: 'Japan', code: 'JP' }, Q668: { label: 'India', code: 'IN' }, Q148: { label: 'China', code: 'CN' }, Q45: { label: 'Portugal', code: 'PT' }, Q142: { label: 'France', code: 'FR' }, Q29: { label: 'Spain', code: 'ES' } };
++const TITLE_QID = { Tokyo: 'Q1490', Delhi: 'Q1353', Shanghai: 'Q8686', Lisbon: 'Q597', Paris: 'Q90', Barcelona: 'Q1492', 'New York City': 'Q60' };
++
++const LIVE = {
++ streams: [{ id: 1, username: 'ada', display_name: 'Ada', title: 'IRL walk in Lisbon', category: 'irl', tags: '[]', viewer_count: 12, thumbnail_url: null, started_at: '2026-10-31 10:00:00' }],
++ recent: [{ id: 2, username: 'bo', display_name: 'Bo', title: 'IRL tour of Paris', category: 'travel', tags: '["irl"]', viewer_count: 0, thumbnail_url: null, started_at: '2026-10-30 09:00:00', ended_at: '2026-10-30 10:00:00', vod_id: 77, vod_thumbnail_url: null }],
++ clips: [{ id: 5, username: 'ada', display_name: 'Ada', title: 'IRL market walk in Lisbon', view_count: 40, thumbnail_url: null, created_at: '2026-10-29 12:00:00', vod_id: 6118 }],
++};
++const LISTINGS = [{ id: 'rnt_01JABCDEFGHJKMNPQRSTVWXYZ0', kind: 'apartment', title: 'A flat in Alfama', description: 'A small flat.', price: 1200, currency: 'EUR', period: 'month', city: 'Lisbon', country: 'PT', neighbourhood: 'Alfama', bedrooms: 1 }];
++
++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 = {
++ pages: JSON.parse(JSON.stringify(WIKIVOYAGE)),
++ streams: JSON.parse(JSON.stringify(LIVE.streams)),
++ recent: JSON.parse(JSON.stringify(LIVE.recent)),
++ clips: JSON.parse(JSON.stringify(LIVE.clips)),
++ listings: JSON.parse(JSON.stringify(LISTINGS)),
++ overpass: null,
++ down: {},
++ requests: [],
++ };
++
++ const srv = await listen(async (req, raw, json) => {
++ const url = new URL(req.url, 'http://x');
++ state.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw });
++ if (state.down[url.pathname] || state.down.all) return json(503, { error: 'source down' });
++
++ if (url.pathname === '/w/api.php') {
++ const action = url.searchParams.get('action');
++ if (action === 'parse') {
++ const title = url.searchParams.get('page');
++ const text = state.pages[title];
++ if (!text) return json(200, { error: { code: 'missingtitle', info: 'no such page' } });
++ return json(200, { parse: { title, pageid: 1, revid: 2, wikitext: text } });
++ }
++ if (action === 'query' && url.searchParams.get('prop') === 'pageprops') {
++ const titles = String(url.searchParams.get('titles') || '').split('|');
++ const pages = titles.filter((t) => TITLE_QID[t]).map((t) => ({ title: t, pageprops: { wikibase_item: TITLE_QID[t] } }));
++ return json(200, { query: { pages } });
++ }
++ if (action === 'query' && url.searchParams.get('list') === 'search') {
++ const q = String(url.searchParams.get('srsearch') || '');
++ const hit = Object.keys(state.pages).find((p) => p.toLowerCase().includes(q.toLowerCase()));
++ return json(200, { query: { search: hit ? [{ title: hit }] : [] } });
++ }
++ if (action === 'wbgetentities') {
++ const ids = String(url.searchParams.get('ids') || '').split('|');
++ const entities = {};
++ for (const id of ids) {
++ const e = ENTITIES[id];
++ if (e) {
++ entities[id] = {
++ id, labels: { en: { language: 'en', value: e.label } },
++ claims: {
++ P625: [{ mainsnak: { datavalue: { value: { latitude: e.lat, longitude: e.lon } } } }],
++ P1082: [{ mainsnak: { datavalue: { value: { amount: `+${e.population}` } } } }],
++ P18: [{ mainsnak: { datavalue: { value: e.image } } }],
++ P17: [{ mainsnak: { datavalue: { value: { id: e.country } } } }],
++ },
++ sitelinks: { enwikivoyage: { title: e.sitelink } },
++ };
++ } else if (COUNTRIES[id]) {
++ entities[id] = { id, labels: { en: { language: 'en', value: COUNTRIES[id].label } }, claims: { P297: [{ mainsnak: { datavalue: { value: COUNTRIES[id].code } } }] } };
++ }
++ }
++ return json(200, { entities });
++ }
++ if (action === 'query' && url.searchParams.get('prop') === 'imageinfo') {
++ const titles = String(url.searchParams.get('titles') || '').split('|');
++ const pages = titles.map((t) => ({ title: t, imageinfo: [{ thumburl: 'https://upload.wikimedia.org/tours-test.jpg', url: 'https://upload.wikimedia.org/tours-test.jpg', extmetadata: { Artist: { value: 'A photographer' }, LicenseShortName: { value: 'CC BY-SA 4.0' }, LicenseUrl: { value: 'https://creativecommons.org/licenses/by-sa/4.0/' } } }] }));
++ return json(200, { query: { pages } });
++ }
++ return json(400, { error: 'unknown action' });
++ }
++
++ if (url.pathname === '/interpreter' && req.method === 'POST') {
++ const decoded = decodeURIComponent(raw.replace(/\+/g, ' '));
++ const m = /around:(\d+),(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/.exec(decoded);
++ const lat = m ? Number(m[2]) : 35.6762;
++ const lon = m ? Number(m[3]) : 139.6503;
++ const named = [
++ { at: [35.6762, 139.6503], id: 21, name: 'Senso-ji', lat: 35.7148, lon: 139.7967 },
++ { at: [28.6139, 77.2090], id: 22, name: 'Red Fort', lat: 28.6562, lon: 77.2410 },
++ { at: [31.2304, 121.4737], id: 23, name: 'The Bund', lat: 31.2400, lon: 121.4900 },
++ { at: [38.7223, -9.1393], id: 24, name: 'Castelo de São Jorge', lat: 38.7139, lon: -9.1335 },
++ { at: [48.8566, 2.3522], id: 25, name: 'Eiffel Tower', lat: 48.8584, lon: 2.2945 },
++ { at: [41.3874, 2.1686], id: 26, name: 'Sagrada Família', lat: 41.4036, lon: 2.1744 },
++ ].find((x) => Math.abs(x.at[0] - lat) < 0.2 && Math.abs(x.at[1] - lon) < 0.2);
++ const extra = named ? [{ type: 'node', id: named.id, lat: named.lat, lon: named.lon, tags: { name: named.name, tourism: 'attraction', wikidata: 'Q3' } }] : [];
++ const seed = 100000 + Math.round((Math.abs(lat) * 1000 + Math.abs(lon) * 10)) * 10;
++ const base = state.overpass || [
++ ...extra,
++ { type: 'node', id: seed + 1, lat: lat + 0.002, lon: lon + 0.002, tags: { name: 'Museum of the city', tourism: 'museum', wheelchair: 'yes', opening_hours: 'Tu-Su 10:00-18:00', website: 'https://museum.example/', wikidata: 'Q1' } },
++ { type: 'node', id: seed + 2, lat: lat + 0.004, lon: lon - 0.002, tags: { name: 'The old tower', tourism: 'attraction', fee: 'no', wikidata: 'Q2' } },
++ { type: 'way', id: seed + 3, center: { lat: lat - 0.003, lon: lon + 0.001 }, tags: { name: 'City park', tourism: 'attraction', fee: 'no' } },
++ { type: 'node', id: seed + 4, lat: lat - 0.001, lon: lon - 0.004, tags: { name: 'Hotel not a place', tourism: 'hotel' } },
++ ];
++ return json(200, { elements: base });
++ }
++
++ if (url.pathname === '/api/streams') return json(200, { streams: state.streams });
++ if (url.pathname === '/api/streams/recent') return json(200, { streams: state.recent });
++ if (url.pathname === '/api/clips') return json(200, { clips: state.clips });
++ if (url.pathname === '/api/v1/listings') {
++ const city = url.searchParams.get('city');
++ const limit = Number(url.searchParams.get('limit') || 30);
++ const rows = state.listings.filter((l) => !city || l.city.toLowerCase() === city.toLowerCase()).slice(0, limit);
++ return json(200, { count: rows.length, listings: rows, facets: { kind: [], country: [] }, next: null });
++ }
++ return json(404, { error: 'not found' });
++ });
++ return { url: srv.url, state, close: srv.close };
++}
++
++module.exports = { startUpstreams, WIKIVOYAGE, ENTITIES, COUNTRIES, LIVE, LISTINGS };
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
nnew file mode 100644
iindex 0000000..9f22e88
---- /dev/null
++++ b/test/jobs.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/**
++ * The source jobs against the fixture sources: the top cities and their Wikivoyage pages, Overpass places, generated
++ * routes from See lists, Live IRL streams/VODs and Rent stays. One run is counted, a second run is idempotent, and
++ * every row keeps its source URL and fetch time. The receipt is in tours_job_runs.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('cities: Wikidata coordinates, Commons licence, Wikivoyage summary and a See source', async () => {
++ const out = await t.ctx.jobs.runOnce('cities', { limit: 100 });
++ assert.strictEqual(out.status, 'ok', JSON.stringify(out));
++ assert.strictEqual(out.counts.cities, 6);
++ assert.ok(out.counts.withSummary >= 5);
++ const tokyo = await t.ctx.s.db.maybe("SELECT * FROM tours_cities WHERE slug = 'tokyo'");
++ assert.strictEqual(tokyo.wikidata_id, 'Q1490');
++ assert.strictEqual(tokyo.image_license, 'CC BY-SA 4.0');
++ assert.match(tokyo.image_credit, /photographer/i);
++ assert.strictEqual(tokyo.country, 'Japan');
++ assert.strictEqual(tokyo.country_code, 'JP');
++ assert.match(tokyo.summary, /capital of Japan/);
++ const lisbon = await t.ctx.s.db.maybe("SELECT * FROM tours_cities WHERE slug = 'lisbon'");
++ assert.match(lisbon.source_url, /wikivoyage\.org\/wiki\/Lisbon/);
++ const src = await t.ctx.s.db.maybe('SELECT * FROM tours_city_sources WHERE city_id = $1', [lisbon.id]);
++ const parsed = JSON.parse(src.wikitext);
++ assert.ok(parsed.pages.some((p) => p.title === 'Lisbon/Alfama'), 'the district page is stored');
++ assert.ok(parsed.see.filter((x) => x.hasCoords).length >= 4);
++ });
++
++ await check('places: tourism=* only, stable ids, OSM source and fetch time; a hotel is not a place', async () => {
++ const out = await t.ctx.jobs.runOnce('places', { limit: 100 });
++ assert.strictEqual(out.status, 'ok', JSON.stringify(out));
++ assert.strictEqual(out.counts.places, 18);
++ assert.ok(out.counts.wikivoyagePlaces >= 35);
++ const rows = await t.ctx.s.db.many("SELECT * FROM tours_places WHERE source = 'openstreetmap' ORDER BY id");
++ assert.strictEqual(rows.length, 18);
++ assert.ok(rows.every((p) => /openstreetmap\.org/.test(p.source_url) && p.fetched_at));
++ assert.ok(!rows.some((p) => p.kind === 'hotel'));
++ const wv = await t.ctx.s.db.many("SELECT * FROM tours_places WHERE source = 'wikivoyage'");
++ assert.ok(wv.length >= 30);
++ assert.ok(wv.every((p) => /wikivoyage\.org/.test(p.source_url) && p.osm_type === 'wikivoyage'));
++ const before = (await t.ctx.s.db.many('SELECT id FROM tours_places ORDER BY id')).map((p) => p.id).join(',');
++ await t.ctx.s.db.exec('UPDATE tours_cities SET osm_fetched_at = NULL');
++ const again = await t.ctx.jobs.runOnce('places', { limit: 100 });
++ assert.strictEqual(again.counts.places, 18);
++ const after = (await t.ctx.s.db.many('SELECT id FROM tours_places ORDER BY id')).map((p) => p.id).join(',');
++ assert.strictEqual(after, before, 'a rerun updates the same place ids');
++ assert.ok(t.upstreams.state.requests.some((r) => r.path === '/interpreter' && /OpenVibeTours/.test(r.headers['user-agent'])));
++ });
++
++ await check('routes: generated from Wikivoyage See lists, cited, audio off, idempotent', async () => {
++ const out = await t.ctx.jobs.runOnce('routes', { limit: 100 });
++ assert.strictEqual(out.status, 'ok', JSON.stringify(out));
++ assert.strictEqual(out.counts.routes, 6);
++ assert.strictEqual(out.counts.stops, 36);
++ const route = await t.ctx.s.db.maybe("SELECT * FROM tours_routes WHERE slug = 'tokyo-see-1'");
++ assert.strictEqual(route.generated, true);
++ assert.match(route.source_url, /wikivoyage\.org/);
++ const stops = await t.ctx.s.db.many('SELECT * FROM tours_route_stops WHERE route_id = $1 ORDER BY position', [route.id]);
++ assert.strictEqual(stops.length, 6);
++ assert.ok(stops.every((s) => s.audio_status === 'none' && s.audio_url === null));
++ assert.ok(stops.every((s) => /wikivoyage\.org/.test(s.source_url)));
++ assert.ok(stops.some((s) => s.place_id), 'a stop matches an OSM place by name or proximity');
++ const again = await t.ctx.jobs.runOnce('routes', { limit: 100 });
++ assert.strictEqual(again.counts.routes, 6);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_routes'), 6);
++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int FROM tours_route_stops'), 36);
++ });
++
++ await check('live: IRL streams, VODs and clips only, public Live/video URLs, a city when the title names one', async () => {
++ const out = await t.ctx.jobs.runOnce('live');
++ assert.strictEqual(out.status, 'ok', JSON.stringify(out));
++ assert.strictEqual(out.counts.streams, 1);
++ assert.strictEqual(out.counts.vods, 1);
++ assert.strictEqual(out.counts.clips, 1);
++ assert.strictEqual(out.counts.withCity, 3);
++ const stream = await t.ctx.s.db.maybe("SELECT * FROM tours_live WHERE kind = 'stream'");
++ assert.strictEqual(stream.city_name, 'Lisbon');
++ assert.strictEqual(stream.url, 'https://openvibe.live/@ada');
++ const vod = await t.ctx.s.db.maybe("SELECT * FROM tours_live WHERE kind = 'vod'");
++ assert.strictEqual(vod.url, 'https://openvibe.video/v/77');
++ assert.strictEqual(vod.city_name, 'Paris');
++ const clip = await t.ctx.s.db.maybe("SELECT * FROM tours_live WHERE kind = 'clip'");
++ assert.strictEqual(clip.url, 'https://openvibe.video/c/5');
++ assert.strictEqual(clip.city_name, 'Lisbon');
++ });
++
++ await check('stays: a Rent listing for its city with the price the listing states', async () => {
++ const out = await t.ctx.jobs.runOnce('stays', { limit: 100 });
++ assert.strictEqual(out.status, 'ok', JSON.stringify(out));
++ assert.strictEqual(out.counts.stays, 1);
++ const stay = await t.ctx.s.db.maybe('SELECT * FROM tours_stays');
++ assert.strictEqual(stay.city_name, 'Lisbon');
++ assert.strictEqual(Number(stay.price), 1200);
++ assert.strictEqual(stay.currency, 'EUR');
++ assert.match(stay.url, /^https:\/\/openvibe\.rent\/listings\/rnt_/);
++ });
++
++ await check('every job left a receipt with its counts', async () => {
++ const rows = await t.ctx.s.db.many("SELECT DISTINCT ON (job) job, status, counts FROM tours_job_runs ORDER BY job, started_at DESC");
++ assert.deepStrictEqual(rows.map((r) => r.job).sort(), ['cities', 'live', 'places', 'routes', 'stays']);
++ for (const row of rows) assert.ok(['ok', 'partial'].includes(row.status), `${row.job}: ${row.status}`);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/migration.test.js b/test/migration.test.js
nnew file mode 100644
iindex 0000000..ef7ecee
---- /dev/null
++++ b/test/migration.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++/**
++ * The migration applies in one pass and its constraints are real: a stable city/place/route id, a valid trip window,
++ * a shared trip slug, a route stop that cannot claim ready audio without a URL, and one listing per Rent id.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { stableId } = require('../server/store');
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('0001_initial.sql creates the Tours tables and the SDK 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 ['tours_cities', 'tours_city_sources', 'tours_places', 'tours_routes', 'tours_route_stops',
++ 'tours_live', 'tours_stays', 'tours_trips', 'tours_trip_places', 'tours_trip_routes', 'tours_trip_stays',
++ 'tours_job_runs', 'account_data_events', 'telemetry_samples']) {
++ assert.ok(names.includes(table), `missing ${table}`);
++ }
++ });
++
++ await check('a trip cannot end before it starts, take more than 60 days, or use a bad id/slug/owner', async () => {
++ const db = t.ctx.s.db;
++ const id = `trp_${require('openvibe-contracts').ids.ulid()}`;
++ const owner = require('openvibe-contracts').ids.newId('user');
++ const base = `INSERT INTO tours_trips (id, slug, owner_subject, title, starts_on, ends_on, visibility) VALUES ($1,$2,$3,$4,$5::date,$6::date,$7)`;
++ await db.exec(base, [id, 'trip-0123456789abcdef01234567', owner, 'A trip', '2026-11-10', '2026-11-14', 'private']);
++ await assert.rejects(db.exec(base, [`trp_${require('openvibe-contracts').ids.ulid()}`, 'trip-0123456789abcdef01234568', owner, 'Bad', '2026-11-14', '2026-11-10', 'private']), /tours_trips_dates/);
++ await assert.rejects(db.exec(base, [`trp_${require('openvibe-contracts').ids.ulid()}`, 'trip-0123456789abcdef01234569', owner, 'Long', '2026-01-01', '2026-04-01', 'private']), /tours_trips_dates/);
++ await assert.rejects(db.exec(base, ['bad_id', 'trip-0123456789abcdef01234570', owner, 'Bad id', '2026-11-10', '2026-11-14', 'private']), /tours_trips_id/);
++ await assert.rejects(db.exec(base, [`trp_${require('openvibe-contracts').ids.ulid()}`, 'not-a-trip', owner, 'Bad slug', '2026-11-10', '2026-11-14', 'private']), /tours_trips_slug/);
++ await assert.rejects(db.exec(base, [`trp_${require('openvibe-contracts').ids.ulid()}`, 'trip-0123456789abcdef01234571', 'kim', 'Bad owner', '2026-11-10', '2026-11-14', 'private']), /tours_trips_owner/);
++ await assert.rejects(db.exec(base, [`trp_${require('openvibe-contracts').ids.ulid()}`, 'trip-0123456789abcdef01234572', owner, 'Bad visibility', '2026-11-10', '2026-11-14', 'secret']), /tours_trips_visibility/);
++ });
++
++ await check('a route stop cannot claim ready audio without an https URL', async () => {
++ const db = t.ctx.s.db;
++ const cityId = stableId('cty', 'Q597');
++ await db.exec(`INSERT INTO tours_cities (id, slug, name, country, lat, lon, wikidata_id, source_url, fetched_at)
++ VALUES ($1, 'lisbon', 'Lisbon', 'Portugal', 38.72, -9.13, 'Q597', 'https://www.wikidata.org/wiki/Q597', now())
++ ON CONFLICT (id) DO NOTHING`, [cityId]);
++ const routeId = stableId('rte', 'Q597:see:0');
++ await db.exec(`INSERT INTO tours_routes (id, city_id, slug, title, source_url, fetched_at, stop_count)
++ VALUES ($1,$2,'lisbon-see-1','Lisbon walk','https://en.wikivoyage.org/wiki/Lisbon', now(), 1)`, [routeId, cityId]);
++ await assert.rejects(db.exec(`INSERT INTO tours_route_stops (route_id, position, title, lat, lon, source_url, audio_status, audio_url)
++ VALUES ($1, 1, 'A stop', 38.7, -9.1, 'https://en.wikivoyage.org/wiki/Lisbon', 'ready', NULL)`, [routeId]), /tours_route_stops_audio_url/);
++ await db.exec(`INSERT INTO tours_route_stops (route_id, position, title, lat, lon, source_url) VALUES ($1, 1, 'A stop', 38.7, -9.1, 'https://en.wikivoyage.org/wiki/Lisbon')`, [routeId]);
++ assert.strictEqual((await db.maybe('SELECT audio_status FROM tours_route_stops WHERE route_id = $1', [routeId])).audio_status, 'none');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..1378a43
---- /dev/null
++++ b/test/pages.test.js
@@@ -0,0 +1,113 @@
++'use strict';
++/**
++ * The pages are whole without JavaScript and carry real data: the room with live/place-of-day/cities/routes/stays,
++ * a city with its places and routes, a place with the TouristAttraction JSON-LD, a route with the MapLibre map and
++ * the TouristTrip itinerary, the trip sign-in, discovery artifacts, and the 404.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++
++(async () => {
++ const t = await boot();
++ try {
++ await seed(t);
++
++ await check('home: hero, live rail, place of the day, top cities, routes, stays, FAQ and the dev section', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('cache-control'), /public, max-age=60/);
++ for (const needle of ['Go anywhere: fly, stay, walk, watch', 'Live tours now', 'Place of the day', 'Top cities', 'Self-guided routes', 'Stays', 'Questions', 'The API', 'OpenStreetMap', 'Wikivoyage', 'Wikidata']) {
++ assert.ok(r.text.includes(needle), needle);
++ }
++ assert.match(r.text, /IRL walk in Lisbon/);
++ assert.match(r.text, /href="\/city\/lisbon"/);
++ assert.match(r.text, /href="\/routes\/rte_/);
++ assert.match(r.text, /href="\/stays"/);
++ assert.match(r.text, /TouristAttraction|TouristDestination|FAQPage/);
++ assert.match(r.text, /<main id="main"/);
++ });
++
++ await check('city page: summary, image licence, places, routes, live, stays and Tourist JSON-LD', async () => {
++ const r = await t.get('/city/lisbon');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Lisbon/);
++ assert.match(r.text, /capital of Portugal|seven hills/);
++ assert.match(r.text, /CC BY-SA 4\.0/);
++ assert.match(r.text, /"@type":"TouristDestination"/);
++ assert.match(r.text, /"@type":"TouristAttraction"/);
++ assert.match(r.text, /"@type":"TouristTrip"/);
++ assert.match(r.text, /href="\/place\/plc_/);
++ assert.match(r.text, /href="\/routes\/rte_/);
++ assert.match(r.text, /A flat in Alfama/);
++ assert.match(r.text, /IRL walk in Lisbon/);
++ assert.strictEqual((await t.get('/city/nowhere')).status, 404);
++ });
++
++ await check('place page: TouristAttraction JSON-LD, OSM link, source and fetch time', async () => {
++ const place = await t.ctx.s.db.maybe("SELECT id FROM tours_places WHERE source = 'openstreetmap' LIMIT 1");
++ const r = await t.get(`/place/${place.id}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /"@type":"TouristAttraction"/);
++ assert.match(r.text, /openstreetmap\.org\/node\//);
++ assert.match(r.text, /fetched/);
++ assert.match(r.text, /OpenStreetMap/);
++ });
++
++ await check('route page: MapLibre, the fallback SVG, cited stops, audio off and TouristTrip itinerary', async () => {
++ const route = await t.ctx.s.db.maybe("SELECT id FROM tours_routes WHERE slug = 'tokyo-see-1'");
++ const r = await t.get(`/routes/${route.id}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /"@type":"TouristTrip"/);
++ assert.match(r.text, /"@type":"TouristAttraction"/);
++ assert.match(r.text, /id="tr-maplibre"/);
++ assert.match(r.text, /maplibre-gl@/);
++ assert.match(r.text, /id="tr-map-fallback"/);
++ assert.match(r.text, /OpenStreetMap/);
++ assert.match(r.text, /Wikivoyage note/);
++ assert.match(r.text, /OpenVibe\.AI has no speech-synthesis API today/);
++ assert.match(r.text, /© OpenStreetMap contributors/);
++ });
++
++ await check('live, stays, flights, developers and 404 render; trips ask for sign-in', async () => {
++ const live = await t.get('/live');
++ assert.strictEqual(live.status, 200);
++ assert.match(live.text, /IRL walk in Lisbon/);
++ assert.match(live.text, /Live tours now/);
++ const stays = await t.get('/stays');
++ assert.strictEqual(stays.status, 200);
++ assert.match(stays.text, /A flat in Alfama/);
++ const flights = await t.get('/flights');
++ assert.strictEqual(flights.status, 200);
++ assert.match(flights.text, /official airline-neutral search/);
++ const dev = await t.get('/developers');
++ assert.strictEqual(dev.status, 200);
++ assert.match(dev.text, /\/api\/v1\/openapi\.json/);
++ assert.match(dev.text, /OpenStreetMap via Overpass/);
++ const trips = await t.get('/trips');
++ assert.strictEqual(trips.status, 401);
++ assert.match(trips.text, /Sign in with OpenVibe/);
++ assert.strictEqual((await t.get('/nope')).status, 404);
++ });
++
++ await check('discovery: robots, sitemap, llms and llms-full list the public pages', async () => {
++ const robots = await t.get('/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ assert.match(robots.text, /Sitemap: https:\/\/openvibe\.tours\/sitemap\.xml/);
++ assert.match(robots.text, /Disallow: \/trips/);
++ const sitemap = await t.get('/sitemap.xml');
++ assert.strictEqual(sitemap.status, 200);
++ assert.match(sitemap.text, /\/city\/lisbon/);
++ assert.match(sitemap.text, /\/routes\/rte_/);
++ const llms = await t.get('/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.match(llms.text, /OpenVibe\.Tours/);
++ const full = await t.get('/llms-full.txt');
++ assert.strictEqual(full.status, 200);
++ assert.match(full.text, /Lisbon/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..3a7d4f5
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,21 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
++ * in-process mock of OpenVibe.Network, and in-process Live/Chat/Today/OSM/Ticketmaster fixtures; none needs the
++ * network.
++ *
++ * npm test # everything
++ * npm test -- importer # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ */
++'use strict';
++const { run } = require('openvibe-shared/test-runner');
++
++// VERBOSE=1 also prints every file's output, then the summary again.
++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/sso.test.js b/test/sso.test.js
nnew file mode 100644
iindex 0000000..9504436
---- /dev/null
++++ b/test/sso.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256 (client id tours, cookies tours_at/tours_rt/tours_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 tours', async () => {
++ const r = await t.get('/auth/login?next=/trips');
++ 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'), 'tours');
++ 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'), /tours_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=/trips');
++ 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'), '/trips');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /tours_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /tours_rt=[^;]+;.*HttpOnly/i);
++ assert.match(set, /tours_oauth=;/);
++ const at = set.match(/tours_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `tours_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(/tours_oauth=([^;]+)/)[1]);
++ assert.strictEqual(JSON.parse(flow).next, '/');
++ const config = { baseUrl: 'https://openvibe.tours' };
++ assert.strictEqual(sanitizeNext('/trips?x=1', config), '/trips?x=1');
++ assert.strictEqual(sanitizeNext('https://openvibe.tours/trips/x?y=1', config), '/trips/x?y=1');
++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.tours.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
++ });
++
++ await check('readiness is truthful: db required; JWKS, OAuth 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);
++ });
++
++ 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, 'tours');
++ 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 ['/', '/cities', '/routes', '/flights', '/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: 'tours' });
++ 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: `tours_at=${fedcm}` })).status, 401);
++ const page = await t.get('/', { cookie: `tours_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: `tours_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: 'tours_at=garbage' })).status, 401);
++ assert.strictEqual((await t.get('/auth/me', { cookie: 'tours_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(!/tours_at=;|tours_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=/trips', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(same.headers.get('location'), '/trips');
++ assert.match(String(same.headers.get('set-cookie') || ''), /tours_at=;/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/trips.test.js b/test/trips.test.js
nnew file mode 100644
iindex 0000000..209c31f
---- /dev/null
++++ b/test/trips.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++/**
++ * Trips belong to one Network subject: create, read, edit, add a place/route/stay, share read-only, and delete.
++ * Another person can read a shared trip but can never change it; a private trip is a 404 to them. A session write
++ * from another site is refused, and the HTML form path works the same as the API.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { seed } = require('./helpers/seed');
++
++(async () => {
++ const t = await boot();
++ try {
++ await seed(t);
++ const kim = t.network.addUser('kim');
++ const sam = t.network.addUser('sam');
++
++ await check('create: a valid city and dates; a bad date or a 61-day trip is refused', async () => {
++ const r = await t.get('/api/v1/trips', { as: kim, json: { title: 'Lisbon in November', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-14', notes: 'Trams and tiles.' } });
++ assert.strictEqual(r.status, 201);
++ assert.match(r.headers.get('location'), /^\/api\/v1\/trips\/trip-/);
++ assert.strictEqual(r.json().visibility, 'private');
++ assert.strictEqual(r.json().city_id != null, true);
++ for (const body of [
++ { title: 'Bad', city_slug: 'lisbon', starts_on: '2026-11-14', ends_on: '2026-11-10' },
++ { title: 'Long', city_slug: 'lisbon', starts_on: '2026-01-01', ends_on: '2026-04-01' },
++ { title: 'No city', city_slug: 'nowhere', starts_on: '2026-11-10', ends_on: '2026-11-11' },
++ { title: 'x', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-11' },
++ ]) {
++ const bad = await t.get('/api/v1/trips', { as: kim, json: body });
++ assert.strictEqual(bad.status, 400, JSON.stringify(body));
++ assert.strictEqual(bad.json().code, 'tours.trip.invalid');
++ }
++ });
++
++ const list = await t.get('/api/v1/trips', { as: kim });
++ const trip = list.json().trips[0];
++ assert.ok(trip);
++
++ await check('add a place, a route in the trip city, and a stay; the trip returns them in order', async () => {
++ const place = await t.ctx.s.db.maybe("SELECT id FROM tours_places WHERE name = 'Senso-ji'");
++ const lisbonRoute = await t.ctx.s.db.maybe("SELECT id FROM tours_routes WHERE slug = 'lisbon-see-1'");
++ const tokyoRoute = await t.ctx.s.db.maybe("SELECT id FROM tours_routes WHERE slug = 'tokyo-see-1'");
++ const stay = await t.ctx.s.db.maybe('SELECT id FROM tours_stays LIMIT 1');
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/places`, { as: kim, json: { place_id: place.id, note: 'Go early.' } })).status, 201);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/route`, { as: kim, json: { route_id: lisbonRoute.id } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/route`, { as: kim, json: { route_id: tokyoRoute.id } })).status, 400);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/stays`, { as: kim, json: { stay_id: stay.id } })).status, 201);
++ const own = await t.get(`/api/v1/trips/${trip.slug}`, { as: kim });
++ assert.strictEqual(own.status, 200);
++ const b = own.json();
++ assert.strictEqual(b.mine, true);
++ assert.strictEqual(b.places.length, 1);
++ assert.strictEqual(b.places[0].trip_note, 'Go early.');
++ assert.strictEqual(b.route.id, lisbonRoute.id);
++ assert.strictEqual(b.route.stops.length, 6);
++ assert.strictEqual(b.stays.length, 1);
++ });
++
++ await check('the owner can edit title and dates; another person cannot touch the trip', async () => {
++ const patched = await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'PATCH', json: { title: 'Lisbon, slowly', ends_on: '2026-11-15' } });
++ assert.strictEqual(patched.status, 200);
++ assert.strictEqual(patched.json().title, 'Lisbon, slowly');
++ const foreign = await t.get(`/api/v1/trips/${trip.slug}`, { as: sam });
++ assert.strictEqual(foreign.status, 404, 'a private trip is a 404 to another person');
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: sam, method: 'PATCH', json: { title: 'Mine now' } })).status, 403);
++ });
++
++ await check('sharing: the link is read-only for anyone; only the owner can edit or unshare', async () => {
++ const share = await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'PATCH', json: { visibility: 'shared' } });
++ assert.strictEqual(share.status, 200);
++ const anon = await t.get(`/api/v1/trips/${trip.slug}`);
++ assert.strictEqual(anon.status, 200);
++ assert.strictEqual(anon.json().mine, false);
++ assert.strictEqual(anon.json().trip.visibility, 'shared');
++ const other = await t.get(`/api/v1/trips/${trip.slug}`, { as: sam });
++ assert.strictEqual(other.status, 200);
++ assert.strictEqual(other.json().mine, false);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: sam, method: 'DELETE' })).status, 403);
++ const page = await t.get(`/trips/${trip.slug}`);
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /Shared read-only/);
++ assert.ok(!page.text.includes('Delete this trip'), 'a reader gets no owner controls');
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'PATCH', json: { visibility: 'private' } })).json().visibility, 'private');
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`)).status, 404);
++ });
++
++ await check('remove a place, the route and a stay; delete the trip', async () => {
++ await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'PATCH', json: { visibility: 'shared' } });
++ const b = (await t.get(`/api/v1/trips/${trip.slug}`, { as: kim })).json();
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/places/${b.places[0].id}`, { as: kim, method: 'DELETE' })).status, 204);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/route`, { as: kim, json: { route_id: null } })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}/stays/${b.stays[0].id}`, { as: kim, method: 'DELETE' })).status, 204);
++ const after = (await t.get(`/api/v1/trips/${trip.slug}`, { as: kim })).json();
++ assert.strictEqual(after.places.length, 0);
++ assert.strictEqual(after.route, null);
++ assert.strictEqual(after.stays.length, 0);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: kim, method: 'DELETE' })).status, 204);
++ assert.strictEqual((await t.get(`/api/v1/trips/${trip.slug}`, { as: kim })).status, 404);
++ });
++
++ await check('a signed-in session write from another site is 403; the form path creates a trip', async () => {
++ const cross = await t.get('/api/v1/trips', { as: kim, json: { title: 'Cross site', city_slug: 'lisbon', starts_on: '2026-11-10', ends_on: '2026-11-11' }, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403);
++ assert.strictEqual(cross.json().code, 'tours.cross_site');
++ const form = await t.get('/trips', { as: kim, form: { title: 'A form trip', city: 'lisbon', starts_on: '2026-12-01', ends_on: '2026-12-03' } });
++ assert.strictEqual(form.status, 303);
++ assert.match(form.headers.get('location'), /^\/trips\/trip-/);
++ const page = await t.get(form.headers.get('location'), { as: kim });
++ assert.strictEqual(page.status, 200);
++ assert.match(page.text, /A form trip/);
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();
ddiff --git a/test/units.test.js b/test/units.test.js
nnew file mode 100644
iindex 0000000..d0d5108
---- /dev/null
++++ b/test/units.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Pure rules: Wikivoyage wikitext → summary/See listings, stable ids and slugs, Live IRL classification and URL
++ * normalisation, and the Rent listing shape. No network, no database.
++ */
++const assert = require('assert');
++const { parseCityPage, stripWiki, seeListings, districtLinks, leadSummary } = require('../server/sources/wikivoyage');
++const { stableId, slugify } = require('../server/store');
++const { isIrl, utc, vodOf, streamOf } = require('../server/sources/live');
++const { stayOf } = require('../server/sources/rent');
++
++let failures = 0;
++function check(name, fn) {
++ try { fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures += 1; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++
++check('stripWiki keeps the last character, follows links and removes templates, files and refs', () => {
++ assert.strictEqual(stripWiki('Ueno Park'), 'Ueno Park');
++ assert.strictEqual(stripWiki("[[Tokyo/Asakusa|Asakusa]]'s '''Senso-ji''' is [[Buddhist]].", {}), "Asakusa's Senso-ji is Buddhist.");
++ assert.strictEqual(stripWiki('A {{m|28}} statue<ref>source</ref>'), 'A statue');
++ assert.strictEqual(stripWiki('[[File:Photo.jpg|thumb|Caption]] A temple.'), 'A temple.');
++});
++
++check('leadSummary takes the first real paragraph and skips templates, headings and files', () => {
++ const text = "{{pagebanner|X.jpg}}\n\n'''Lisbon''' is Portugal's capital, built on seven hills above the River Tagus, with trams climbing between viewpoints, tiled façades and old neighbourhoods.\n\n==See==\n";
++ assert.match(leadSummary(text), /Portugal's capital/);
++ assert.ok(!leadSummary(text).includes('pagebanner'));
++});
++
++check('seeListings reads name, coordinates, note and old listing templates; districtLinks finds subpages', () => {
++ const wikitext = `==See==
++{{see | name=Senso-ji | lat=35.7148 | long=139.7967 | content=A temple in [[Asakusa]].}}
++{{listing | type=see | name=Ueno Park | lat=35.7156 | long=139.7745 | content=A park.}}
++[[Lisbon/Alfama|Alfama]] and [[Tokyo/Asakusa|Asakusa]]`;
++ const parsed = parseCityPage(wikitext, 'Lisbon');
++ assert.strictEqual(parsed.see.length, 2);
++ assert.strictEqual(parsed.see[0].name, 'Senso-ji');
++ assert.strictEqual(parsed.see[0].lat, 35.7148);
++ assert.strictEqual(parsed.see[0].note, 'A temple in Asakusa.');
++ assert.ok(parsed.see.every((x) => x.hasCoords));
++ assert.deepStrictEqual(parsed.districts, ['Lisbon/Alfama']);
++ assert.deepStrictEqual(districtLinks('[[Lisbon/A|A]] [[Lisbon/B|B]] [[Porto/C|C]]', 'Lisbon', 1), ['Lisbon/A']);
++ assert.strictEqual(seeListings('{{see|name=No coords|content=X}}')[0].hasCoords, false);
++});
++
++check('stable ids are deterministic Crockford ids; slugs strip accents and punctuation', () => {
++ assert.strictEqual(stableId('plc', 'node:1'), stableId('plc', 'node:1'));
++ assert.notStrictEqual(stableId('plc', 'node:1'), stableId('plc', 'node:2'));
++ assert.match(stableId('plc', 'node:1'), /^plc_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(slugify('Bogotá'), 'bogota');
++ assert.strictEqual(slugify('Washington, D.C.'), 'washington-d-c');
++ assert.strictEqual(slugify('Saint Petersburg'), 'saint-petersburg');
++});
++
++check('Live is IRL by category, tag or plain title; timestamps are UTC; VODs link the video page', () => {
++ assert.strictEqual(isIrl({ category: 'irl', title: 'x', tags: '[]' }), true);
++ assert.strictEqual(isIrl({ category: 'other', title: 'x', tags: '["irl"]' }), true);
++ assert.strictEqual(isIrl({ category: 'other', title: 'A walk through the market', tags: '[]' }), true);
++ assert.strictEqual(isIrl({ category: 'gaming', title: 'Ranked', tags: '[]' }), false);
++ assert.strictEqual(utc('2026-10-31 10:00:00'), '2026-10-31T10:00:00.000Z');
++ const item = { id: 2, username: 'bo', title: 'IRL tour', category: 'travel', tags: '["irl"]', started_at: '2026-10-30 09:00:00', ended_at: '2026-10-30 10:00:00', vod_id: 77 };
++ const vod = vodOf(item, { fetchedAt: '2026-11-01T00:00:00.000Z', liveUrl: 'https://openvibe.live', videoBase: 'https://openvibe.video' });
++ assert.strictEqual(vod.url, 'https://openvibe.video/v/77');
++ assert.strictEqual(vod.kind, 'vod');
++ assert.strictEqual(streamOf(item, { fetchedAt: '2026-11-01T00:00:00.000Z', liveUrl: 'https://openvibe.live', videoBase: 'https://openvibe.video' }).url, 'https://openvibe.live/@bo');
++});
++
++check('Rent stays accept only a valid rnt id, kind, period, currency and price', () => {
++ const good = { id: 'rnt_01JABCDEFGHJKMNPQRSTVWXYZ0', kind: 'apartment', title: 'A flat', price: 1200, currency: 'EUR', period: 'month', city: 'Lisbon', neighbourhood: 'Alfama' };
++ const stay = stayOf(good, { fetchedAt: '2026-11-01T00:00:00.000Z', cityId: 'cty_x', cityName: 'Lisbon', rentUrl: 'https://openvibe.rent' });
++ assert.strictEqual(stay.externalId, good.id);
++ assert.strictEqual(stay.price, 1200);
++ assert.strictEqual(stay.url, 'https://openvibe.rent/listings/' + good.id);
++ for (const bad of [{ ...good, id: 'bad' }, { ...good, kind: 'castle' }, { ...good, period: 'century' }, { ...good, currency: 'eur' }, { ...good, price: -1 }]) {
++ assert.strictEqual(stayOf(bad, { fetchedAt: 'x', cityName: 'Lisbon', rentUrl: 'https://openvibe.rent' }), null);
++ }
++});
++
++process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n');
++process.exit(failures ? 1 : 0);