openvibers/openvibe.connect Commits
OpenVibe Connect 0.1.0: your computer, on the web, privately
Changes
nnew file mode 100644@@@ -0,0 +1,36 @@++# OpenVibe Connect — every variable the service reads. Production: /etc/openvibe/connect.env (names only here).++NODE_ENV=development++PORT=5240++HOST=127.0.0.1++BASE_URL=http://localhost:5240++TRUST_PROXY=2++++# PostgreSQL (ADR-035). Unset in development: an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++++# OpenVibe.Network sign-in (OAuth client `connect`).++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++OV_OAUTH_CLIENT_ID=connect++OV_OAUTH_CLIENT_SECRET=++++# OpenVibe.Events: account export/deletion deliveries (comma-separated secrets for rotation).++CONNECT_EVENTS_URL=++CONNECT_EVENTS_SECRET=++++# Shares. The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).++CONNECT_SHARE_DOMAIN=openvibe.website++CONNECT_SESSION_SECRET=++CONNECT_SESSION_HOURS=12++CONNECT_SHARES_PER_PERSON=5++CONNECT_MAX_STREAMS=64++CONNECT_MAX_BODY_BYTES=104857600++CONNECT_REQUEST_TIMEOUT_MS=120000++CONNECT_REQUESTS_PER_MINUTE=1200++CONNECT_BYTES_PER_DAY=10737418240++CONNECT_PING_MS=25000++++# Drill mode (restore drills): no Events subscriptions.++CONNECT_DRILL=nnew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test scripts++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.key++!server/data/nnew file mode 100644@@@ -0,0 +1,27 @@++# Changelog++++What changed in OpenVibe Connect, newest first.++++## 0.1.0 — 2026-11-02++++The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your++computer, on the web, privately.++++- **Shares.** A person makes a share (a name and a local port) and it answers at++ `https://<name>--<user>.openvibe.website`. OpenVibe.Space's site names can never contain "--", so the two never++ meet. Five open shares per person; a closed share keeps its address.++- **The client.** `public/ov-connect.js`: one file, Node 22+, no dependencies. It opens one outgoing WebSocket to++ `wss://connect.openvibe.website/tunnel` (through Cloudflare on 443), says hello with the share's key, and carries++ requests to `localhost:<port>`: HTTP with streaming bodies both ways, server-sent events, and WebSocket upgrades as a++ byte pipe. It reconnects after sleep, stops on a wrong key or a closed share, and a newer client takes over.++- **The gate.** Every visitor signs in with OpenVibe: `/grant` checks the share's policy (only me, me and named++ people, anyone signed in) and hands the share host a one-time code; the host sets its own `__Host-ovc` cookie.++ Changing who may open it signs everyone out of that share.++- **What the app gets.** Host `localhost:<port>`, the share's host in X-Forwarded-Host, the visitor in++ X-OpenVibe-User and X-OpenVibe-Subject; never the gate's cookie. Set-Cookie loses its Domain; nothing is cached by++ Cloudflare.++- **Pages and API.** The front door (sc.room), your shares and the form, a share's page (connected or not, who may++ open it, who did in 7 days, a new key, closing), and `/api/v1/shares` for a person's token.++- **Account export and deletion** (ADR-033): shares (without the key hash) and visits.++- **Checks:** the pure rules, the pages, sign-in, account data, and a share end to end through a real client and a++ real local app.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,91 @@++# OpenVibe Connect++++**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,++`https://<name>--<you>.openvibe.website`, and only the people you pick can open it, signed in with OpenVibe. No router++settings, no open ports, no app to install: one file and Node.++++Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part of openvibe.website: Connect is the++"your machine" half; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.++++## Use it++++1. Sign in at connect.openvibe.website and make a share: a name (`agents`), the port on your computer (`8790`), and++ who can open it.++2. On your computer (Node 22 or newer):++++ ```bash++ curl -fsSLO https://connect.openvibe.website/ov-connect.js++ node ov-connect.js --key ovc_… --port 8790++ ```++++3. Open `https://agents--you.openvibe.website` anywhere, on a phone over 5G included, and sign in.++++The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It++only ever connects to `localhost` on the port you gave it.++++## What your app sees++++- `Host: localhost:<port>`; the share's address in `X-Forwarded-Host`; `X-Forwarded-Proto: https`.++- Who the visitor is: `X-OpenVibe-User` (their username) and `X-OpenVibe-Subject` (their OpenVibe subject id).++ A visitor cannot send these themselves.++- The visitor's cookies for your share's address, never the gate's own cookie.++- WebSockets and server-sent events work. Request bodies up to 100 MB.++++## Who can open a share++++| Mode | Who |++|---|---|++| `owner` (default) | only you |++| `named` | you and the OpenVibe usernames you list |++| `signin` | anyone signed in to OpenVibe |++++Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in++(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.++++## API++++With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):++++| Call | What |++|---|---|++| `GET /api/v1/shares` | your shares, with `connected` |++| `POST /api/v1/shares` `{ label, port, mode?, named? }` | make one → `201 { share, key }` (the key is in this answer only) |++| `GET /api/v1/shares/:id` | one share |++| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note? }` | change it |++| `DELETE /api/v1/shares/:id` | close it (410 from then on) |++| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |++| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |++++## How it works++++```++browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240) ══ WebSocket (ovconnect-ws/1) ══▶ ov-connect.js ──▶ localhost:<port>++ gate: /grant → one-time code → __Host-ovc++```++++- `server/shares.js` — shares, names, keys (hashed), policies, counters.++- `server/edge/tunnels.js` — the computers' WebSockets (`/tunnel`), one per share; a newer one takes over.++- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.++- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.++- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.++- `public/ov-connect.js` — the client.++++The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a++record that shows the origin. The QUIC edge, TCP/UDP shares, custom domains, the private mesh and durable webhooks++are the next steps (CON-3, CON-7, CON-11 to CON-15).++++## Run it++++```bash++npm install++npm run dev # PGlite under data/pglite, http://localhost:5240++npm test # every file on PGlite++npm run test:pg # the same on the PostgreSQL + PgBouncer containers++```++++Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `openvibe-connect`, vhost++`deploy/nginx/connect.openvibe.website.conf`.++++## License++++AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.nnew file mode 100644@@@ -0,0 +1,46 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (connect.openvibe.website and every <name>--<user>.openvibe.website, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,++openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under++github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.++++## What OpenVibe Connect holds++++- A person's shares: their subject id and username, the share's name and address, the local port they typed, who may++ open it, its status, counters (requests, bytes) and times. The connect key is stored only as a SHA-256 hash of a++ 32-byte random value; the key itself is shown once and never stored, logged or rendered again.++- Visits: one row per share, person and day with a request count and a "turned away" count. No IP address, no path, no++ query string, no header, no body.++- Nothing that passes through a share is stored. Requests and responses stream through memory.++- Network session tokens only in httpOnly cookies; no token is stored in the database, rendered into a page or logged.++++## How a share is protected++++- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, the++ owner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the++ Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21).++- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no++ Domain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.++ A Set-Cookie from the shared app loses any Domain attribute, and the app can never set or read the gate's cookie.++- **The share comes from the Host header** nginx sets, never from X-Forwarded-Host or any other visitor header; a++ visitor's X-OpenVibe-* headers are dropped before the app sees the request.++- **No cache past the gate.** Every share response carries `Cloudflare-CDN-Cache-Control: no-store` and a private++ Cache-Control.++- **The client reaches localhost only**, on the port its owner gave it; the server cannot make it connect anywhere else.++- **Limits:** five open shares per person, 64 requests at once and 1,200 a minute per share, 100 MB per request body,++ 10 GB a day per person.++++Please report anything that gets a visitor past the gate, lets one share read another's cookies, or makes a client++reach anything but its own port.nnew file mode 100644@@@ -0,0 +1,44 @@++{++ "repository": "OpenVibers/OpenVibe.Connect",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "plan": "Website Connect v0 (plan 2026-10-28 \u00a75.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.website behind OpenVibe sign-in",++ "runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",++ "port": "5240",++ "domain": "connect.openvibe.website",++ "contracts": "openvibe-contracts v0.278.0",++ "sdk": "openvibe-sdk v0.82.0",++ "shared": "openvibe-shared v4.105.0",++ "packages": [++ "openvibe-contracts v0.278.0",++ "openvibe-sdk v0.82.0",++ "openvibe-shared v4.105.0"++ ],++ "summary": "OpenVibe Connect puts one port on a person's computer on the web at its own HTTPS address, behind OpenVibe sign-in. A one-file client (Node 22, no dependencies) opens one outgoing WebSocket through Cloudflare and carries requests to localhost: HTTP with streaming both ways, server-sent events and WebSocket upgrades. Every visitor signs in and must be allowed by the share's policy (only the owner, named people, or anyone signed in); the share host keeps its own host-only signed cookie. Nothing that passes through is stored.",++ "works": [++ "Shares at <name>--<user>.openvibe.website, never colliding with Space sites; five per person; a closed share keeps its address",++ "The client: hello with a hashed-at-rest key, streams both ways with backpressure, SSE, WebSocket upgrades as a byte pipe, reconnect, takeover, stop on a bad key",++ "The gate: /grant with OpenVibe sign-in, one-time codes, __Host-ovc bound to the share and its policy version",++ "What the app gets: localhost Host, X-Forwarded-Host, X-OpenVibe-User/Subject; Set-Cookie Domain removed; no CDN caching",++ "Pages (front door, your shares, a share's page), the API for a person's token, account export/deletion",++ "Checks on PGlite and PostgreSQL, including a share end to end through the real client"++ ],++ "notYet": [++ "Public shares (abuse check CON-9, Public Suffix List CON-21)",++ "TCP/UDP shares (CON-11/12)",++ "Custom domains (CON-7)",++ "The QUIC edge (CON-3)",++ "The private mesh (CON-13)",++ "Durable webhooks (CON-15)",++ "The OpenVibe Node plugin (CON-4)"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "seed": "none",++ "features": [],++ "deployment": "ovhost connect (port 5240, drill 15240), unit openvibe-connect, database ov_connect",++ "updated": "2026-11-02"++}nnew file mode 100644@@@ -0,0 +1,148 @@++## ═══════════════════════════════════════════════════════════════++## OpenVibe Connect — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The Node app (port 5240,++## systemd unit openvibe-connect) serves two kinds of host:++##++## connect.openvibe.website the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)++## <label>--<user>.openvibe.website a share: the gate, then the tunnel to that person's computer++##++## Every other *.openvibe.website host is OpenVibe.Space's (deploy/nginx/openvibe.website.conf in OpenVibe.Space),++## whose server_name leaves out any host with "--" in it: a Space site's name can never contain "--", so the two never++## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway.++##++## A share streams: no proxy buffering either way (server-sent events, uploads), WebSocket upgrades pass, and a++## connection may stay open for an hour. The app sets every cache header itself (a share is never cached by++## Cloudflare: Cloudflare-CDN-Cache-Control: no-store). Never log request bodies, query strings or cookies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=connect_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=connect_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=connect_tunnel:10m rate=30r/m;++++map $http_upgrade $connect_connection {++ default upgrade;++ '' close;++}++++# The access log for shares keeps no query string (a share's URLs are someone else's app's).++log_format connect_share '$remote_addr - [$time_local] "$request_method $host$uri" $status $body_bytes_sent $request_time';++++server {++ listen 80;++ listen [::]:80;++ server_name connect.openvibe.website ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://$host$request_uri; }++}++++# ── connect.openvibe.website ──────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name connect.openvibe.website;++++ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/connect.openvibe.website.access.log;++ error_log /var/log/nginx/connect.openvibe.website.error.log;++++ client_max_body_size 64k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000" 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;++++ # ── A computer connecting a share: one long WebSocket (the server pings every 25 s) ──++ location = /tunnel {++ limit_req zone=connect_tunnel burst=20 nodelay;++ limit_req_status 429;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connect_connection;++ proxy_buffering off;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ proxy_pass http://127.0.0.1:5240;++ }++++ location = /auth/me {++ limit_req zone=connect_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location /auth/ {++ limit_req zone=connect_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location /api/ {++ limit_req zone=connect_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5240;++ }++++ location ~* ^/metrics(/|$) { return 404; }++ location ~* ^/internal(/|$) { return 404; }++++ location / {++ proxy_pass http://127.0.0.1:5240;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}++++# ── <label>--<user>.openvibe.website: a share ─────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;++++ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/connect-shares.access.log connect_share;++ error_log /var/log/nginx/connect-shares.error.log;++++ # Connect counts a body against its own cap (100 MB); Cloudflare's own cap is the same.++ client_max_body_size 100m;++++ add_header X-Content-Type-Options nosniff always;++++ location / {++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ proxy_set_header Upgrade $http_upgrade;++ proxy_set_header Connection $connect_connection;++ proxy_buffering off;++ proxy_request_buffering off;++ proxy_cache off;++ proxy_read_timeout 3600s;++ proxy_send_timeout 3600s;++ proxy_pass http://127.0.0.1:5240;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.website and every <name>--<user>.openvibe.website)++# Network (sign-in, JWKS, account events) should be up first, but Connect degrades honestly without it: connected++# shares keep passing requests for people already let in, and sign-in waits 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.connect++EnvironmentFile=/etc/openvibe/connect.env++Environment=NODE_ENV=production++Environment=PORT=5240++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.connect/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++# In-flight requests get the 10 s drain; connected computers reconnect to the next process by themselves.++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Connect's own database is PostgreSQL on the host's data role; the code tree stays read-only.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,83 @@++-- phase: expand++-- OpenVibe.Connect (connect.openvibe.website): Website Connect's Share, v0 (ADR-057 as reviewed 2026-11-02, OQ-13).++-- A share is one port on a person's machine served at https://<label>--<user>.openvibe.website behind OpenVibe++-- sign-in. Nothing here is a secret: the connect key is stored only as a SHA-256 hash of a 32-byte random value,++-- and the visitors' rows hold a subject id and a username, never a token, a cookie, a body or a query string.++++CREATE TABLE shares (++ id text PRIMARY KEY, -- shr_<ulid>++ owner_subject text NOT NULL, -- usr_… (Network's canonical subject)++ owner_username text NOT NULL, -- as it was when the share was made (shown, not trusted)++ label text NOT NULL, -- the part before "--"++ host text NOT NULL, -- <label>--<user>.openvibe.website, lowercase++ local_port integer NOT NULL CHECK (local_port BETWEEN 1 AND 65535),++ mode text NOT NULL DEFAULT 'owner' CHECK (mode IN ('owner', 'named', 'signin')),++ named jsonb NOT NULL DEFAULT '[]'::jsonb, -- lowercase usernames a 'named' share lets in++ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),++ policy_version integer NOT NULL DEFAULT 1,++ key_prefix text NOT NULL, -- the first characters of the key, to tell keys apart++ key_hash text NOT NULL, -- hex SHA-256 of the key++ note text,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ revoked_at timestamptz,++ last_connected_at timestamptz,++ last_visit_at timestamptz,++ requests bigint NOT NULL DEFAULT 0,++ bytes_in bigint NOT NULL DEFAULT 0,++ bytes_out bigint NOT NULL DEFAULT 0++);++-- One live share per host; a revoked share keeps its row (and its host stays held, see shares.js).++CREATE UNIQUE INDEX shares_host_active ON shares (host) WHERE status = 'active';++CREATE INDEX shares_owner ON shares (owner_subject, created_at DESC);++CREATE UNIQUE INDEX shares_key_hash ON shares (key_hash);++++-- Who came in, one row per share, person and day: the share page's "who opened it" list. No IP, no path.++CREATE TABLE share_visits (++ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,++ day date NOT NULL,++ subject text NOT NULL,++ username text,++ requests integer NOT NULL DEFAULT 0,++ denied integer NOT NULL DEFAULT 0,++ last_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (share_id, day, subject)++);++++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033).++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);++++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.++CREATE TABLE telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);nnew file mode 100644@@@ -0,0 +1,1718 @@++{++ "name": "openvibe-connect",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-connect",++ "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.278.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",++ "pg": "^8.23.1",++ "ws": "^8.18.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/boolbase": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",++ "license": "ISC",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "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/css-select": {++ "version": "7.0.0",++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0",++ "css-what": "^8.0.0",++ "domhandler": "^6.0.1",++ "domutils": "^4.0.2",++ "nth-check": "^3.0.1"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/css-what": {++ "version": "8.0.0",++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ },++ "node_modules/cssomnom": {++ "version": "0.1.11",++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "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/dom-serializer": {++ "version": "3.1.1",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0",++ "entities": "^8.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/domelementtype": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ }++ },++ "node_modules/domhandler": {++ "version": "6.0.1",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^3.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/domutils": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^3.0.0",++ "domelementtype": "^3.0.0",++ "domhandler": "^6.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "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/entities": {++ "version": "8.1.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "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/html-escaper": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",++ "license": "MIT"++ },++ "node_modules/htmlparser2": {++ "version": "10.1.0",++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",++ "funding": [++ "https://github.com/fb55/htmlparser2?sponsor=1",++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3",++ "domutils": "^3.2.2",++ "entities": "^7.0.1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",++ "license": "MIT",++ "dependencies": {++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.2",++ "entities": "^4.2.0"++ },++ "funding": {++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {++ "version": "4.5.0",++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domelementtype": {++ "version": "2.3.0",++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fb55"++ }++ ],++ "license": "BSD-2-Clause"++ },++ "node_modules/htmlparser2/node_modules/domhandler": {++ "version": "5.0.3",++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "domelementtype": "^2.3.0"++ },++ "engines": {++ "node": ">= 4"++ },++ "funding": {++ "url": "https://github.com/fb55/domhandler?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/domutils": {++ "version": "3.2.2",++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "dom-serializer": "^2.0.0",++ "domelementtype": "^2.3.0",++ "domhandler": "^5.0.3"++ },++ "funding": {++ "url": "https://github.com/fb55/domutils?sponsor=1"++ }++ },++ "node_modules/htmlparser2/node_modules/entities": {++ "version": "7.0.1",++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=0.12"++ },++ "funding": {++ "url": "https://github.com/fb55/entities?sponsor=1"++ }++ },++ "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/linkedom": {++ "version": "0.18.15",++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",++ "license": "ISC",++ "dependencies": {++ "css-select": "^7.0.0",++ "cssomnom": "^0.1.11",++ "html-escaper": "^3.0.3",++ "htmlparser2": "^10.1.0",++ "uhyphen": "^0.2.0"++ },++ "engines": {++ "node": ">=16"++ },++ "peerDependencies": {++ "canvas": ">= 2"++ },++ "peerDependenciesMeta": {++ "canvas": {++ "optional": true++ }++ }++ },++ "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/nth-check": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",++ "license": "BSD-2-Clause",++ "dependencies": {++ "boolbase": "^2.0.0"++ },++ "engines": {++ "node": ">=20.19.0"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/fb55/nth-check?sponsor=1"++ }++ },++ "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.278.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.278.0",++ "integrity": "sha512-B++7iYz9b+5aT88dm+qvicwG2oD5lUXuBmiD03IpbJtXEou0OIJI/AErZoTLM6O7x5Q8o9IWoapKFjyGtF1OqQ==",++ "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.82.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",++ "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",++ "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.105.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",++ "integrity": "sha512-Q+VaFE78fPjCzf/t15Kmnca+UZIrobFHDoigPqn4qdzDUYpvT3gmHTfmqdFniYGDYb3rrDJloLXvb7JcMhKhDw==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"++ },++ "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.6",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",++ "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/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },++ "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/ws": {++ "version": "8.22.1",++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",++ "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",++ "license": "MIT",++ "engines": {++ "node": ">=10.0.0"++ },++ "peerDependencies": {++ "bufferutil": "^4.0.1",++ "utf-8-validate": ">=5.0.2"++ },++ "peerDependenciesMeta": {++ "bufferutil": {++ "optional": true++ },++ "utf-8-validate": {++ "optional": true++ }++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,40 @@++{++ "name": "openvibe-connect",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.website, behind OpenVibe sign-in.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "CONNECT_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe",++ "tunnel",++ "share",++ "localhost"++ ],++ "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.278.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",++ "pg": "^8.23.1",++ "ws": "^8.18.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,143 @@++/* OpenVibe Connect: the pieces sc.room does not draw. Every colour is a theme token (kit.css, room.css); the room's++ accent is openvibe.website's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */++++.cn-wrap { max-width: 1120px; margin: 0 auto; padding: 0 max(16px, env(safe-area-inset-left, 0px)) 48px; }++.cn-page { max-width: 760px; padding-top: 20px; display: grid; gap: 22px; }++.cn-narrow { max-width: 560px; }++.skip { position: absolute; left: 12px; top: -60px; z-index: 1000; padding: 10px 14px; border-radius: 10px; background: var(--room-fill); color: var(--room-on); font-weight: 700; text-decoration: none; }++.skip:focus { top: 12px; }++.cn-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.cn h2 { font-size: 1.15rem; margin: 0 0 12px; text-wrap: balance; }++.cn code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; }++++/* ── The hero's picture: a port, the line, an address ── */++.cn-motif { display: grid; gap: 10px; width: min(100%, 380px); margin: 0 auto; }++.cn-m-box { position: relative; display: grid; gap: 4px; padding: 16px 18px; border-radius: 14px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); }++.cn-m-box b { font: 600 15px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; color: var(--ovk-text); }++.cn-m-k { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted); }++.cn-m-dot { position: absolute; top: 16px; right: 16px; width: 9px; height: 9px; border-radius: 50%; background: var(--ovk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovk-ok) 22%, transparent); }++.cn-m-line { display: flex; align-items: center; gap: 10px; padding-left: 26px; color: var(--ovk-muted); font-size: 12.5px; }++.cn-m-line i { width: 2px; height: 34px; border-radius: 2px; background: linear-gradient(var(--room-accent), color-mix(in srgb, var(--room-accent) 30%, transparent)); }++.cn-m-phone { border-color: color-mix(in srgb, var(--room-accent) 45%, var(--ovk-line)); }++.cn-m-lock { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--room-strong); }++.cn-m-lock svg, .cn-phone-url svg { width: 14px; height: 14px; flex: none; }++++/* ── Scenes: a terminal, a phone, the picker ── */++.cn-term { width: 100%; max-width: 520px; border-radius: 14px; overflow: hidden; border: 1px solid var(--ovk-line); background: color-mix(in srgb, var(--ovk-bg) 70%, #000); }++.cn-term-bar { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--ovk-line); }++.cn-term-bar i { width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--ovk-muted) 45%, transparent); }++.cn-term pre { margin: 0; padding: 14px 16px; overflow-x: auto; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #dfe6f3; white-space: pre; }++.cn-term-cmd { color: #f2f5fb; }++.cn-term-out { color: #9fe0b5; }++:root[data-theme-mode="light"] .cn-term { background: #141a26; }++++.cn-phone { width: min(100%, 260px); margin: 0 auto; border-radius: 28px; padding: 14px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); display: grid; gap: 12px; }++.cn-phone-url { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 999px; background: var(--ovk-input); font-size: 12px; color: var(--ovk-muted); min-width: 0; }++.cn-phone-url span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.cn-phone-url svg { color: var(--ovk-ok-text); }++.cn-phone-body { display: grid; gap: 6px; padding: 18px 14px 22px; border-radius: 16px; background: color-mix(in srgb, var(--room-accent) 10%, var(--ovk-bg)); }++.cn-phone-body b { font-size: 16px; }++.cn-phone-body span { font-size: 13px; color: var(--ovk-muted); }++.cn-phone-pill { justify-self: start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 12px !important; color: var(--room-on) !important; background: var(--room-fill); }++++.cn-pick { width: min(100%, 340px); margin: 0 auto; display: grid; gap: 8px; }++.cn-pick-row { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); font-size: 14px; color: var(--ovk-muted); }++.cn-pick-row i { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--ovk-line); flex: none; }++.cn-pick-row.is-on { color: var(--ovk-text); border-color: color-mix(in srgb, var(--room-accent) 55%, var(--ovk-line)); }++.cn-pick-row.is-on i { border-color: var(--room-accent); background: radial-gradient(circle, var(--room-accent) 0 4px, transparent 5px); }++.cn-pick-names { display: flex; gap: 6px; padding-left: 40px; }++.cn-pick-names b { padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); }++++/* ── Your shares, and the form ── */++.cn-yours { display: grid; gap: 14px; margin: 8px 0 36px; padding: 22px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }++.cn-yours > h2 { margin: 0; }++.cn-shares { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.cn-share a { display: flex; align-items: center; gap: 12px; min-height: var(--ovk-tap, 44px); padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); text-decoration: none; }++.cn-share a:hover, .cn-share a:focus-visible { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--ovk-line)); outline: 0; }++.cn-share a > svg { width: 18px; height: 18px; color: var(--ovk-muted); flex: none; }++.cn-share-main { display: grid; gap: 2px; min-width: 0; flex: 1; }++.cn-share-main b { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; overflow-wrap: anywhere; }++.cn-share-main span { font-size: 13px; color: var(--ovk-muted); }++.cn-share-dot, .cn-state i { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--ovk-muted); }++.is-on.cn-share-dot, .cn-state.is-on i { background: var(--ovk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovk-ok) 20%, transparent); }++.is-off.cn-share-dot, .cn-state.is-off i { background: var(--ovk-warn); }++.is-closed.cn-share-dot, .cn-state.is-closed i { background: var(--ovk-bad); }++.cn-closed summary { cursor: pointer; padding: 10px 0; color: var(--ovk-muted); font-size: 14px; min-height: 24px; }++.cn-empty { margin: 0; color: var(--ovk-muted); }++++.cn-form { display: grid; gap: 14px; padding-top: 6px; }++.cn-yours .cn-form { border-top: 1px solid var(--ovk-line); padding-top: 18px; scroll-margin-top: calc(var(--ovk-top, 52px) + 16px); }++.cn-fields { display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr); }++@media (min-width: 720px) { .cn-fields { grid-template-columns: minmax(0, 1fr) 200px; } }++.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }++.cn-field > span:first-child { font-size: 14px; font-weight: 600; }++.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }++.cn-field input, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }++.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }++.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }++.cn-addr input:focus { outline: 0; }++.cn-addr:focus-within, .cn-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }++.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }++.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }++.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }++.cn-modes label { display: flex; align-items: center; gap: 10px; min-height: var(--ovk-tap, 44px); padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); cursor: pointer; font-size: 15px; }++.cn-modes label:has(input:checked) { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--ovk-line)); }++.cn-modes input { accent-color: var(--room-accent); width: 18px; height: 18px; margin: 0; }++@media (min-width: 720px) { .cn-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); } .cn-modes legend { grid-column: 1 / -1; } }++++.cn-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--ovk-tap, 44px); padding: 0 18px; border-radius: 12px; border: 1px solid transparent; background: var(--room-fill); color: var(--room-on); font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; text-decoration: none; justify-self: start; }++.cn-btn svg { width: 18px; height: 18px; flex: none; }++.cn-btn:hover { filter: brightness(1.06); }++.cn-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.cn-btn-quiet { background: transparent; color: var(--ovk-text); border-color: var(--ovk-line); }++.cn-btn-bad { background: transparent; color: var(--ovk-bad-text); border-color: color-mix(in srgb, var(--ovk-bad) 55%, var(--ovk-line)); }++.cn-fine { margin: 0; font-size: 13px; color: var(--ovk-muted); }++.cn-error, .cn-notice { margin: 0; padding: 12px 14px; border-radius: 12px; font-size: 14px; }++.cn-error { color: var(--ovk-bad-text); background: color-mix(in srgb, var(--ovk-bad) 12%, transparent); border: 1px solid color-mix(in srgb, var(--ovk-bad) 40%, transparent); }++.cn-notice { color: var(--ovk-ok-text); background: color-mix(in srgb, var(--ovk-ok) 12%, transparent); border: 1px solid color-mix(in srgb, var(--ovk-ok) 40%, transparent); }++++/* ── One share ── */++.cn-back { margin: 0; }++.cn-back a { display: inline-flex; align-items: center; gap: 6px; min-height: var(--ovk-tap, 44px); color: var(--ovk-muted); text-decoration: none; font-size: 14px; }++.cn-back svg { width: 16px; height: 16px; }++.cn-head { display: grid; gap: 8px; }++.cn-head h1 { margin: 0; font: 700 clamp(1.15rem, 4.6vw, 1.7rem)/1.25 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }++.cn-head h1 a { color: var(--room-strong); text-decoration: none; }++.cn-head h1 a:hover { text-decoration: underline; }++.cn-head p { margin: 0; color: var(--ovk-muted); }++.cn-state { display: inline-flex; align-items: center; gap: 8px; justify-self: start; padding: 4px 12px 4px 10px; border-radius: 999px; font-size: 13px; font-weight: 600; border: 1px solid var(--ovk-line); }++++.cn-key { display: grid; gap: 12px; padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid color-mix(in srgb, var(--room-accent) 50%, var(--ovk-line)); background: color-mix(in srgb, var(--room-accent) 7%, var(--ovk-panel)); }++.cn-key h2, .cn-key h3 { margin: 0; }++.cn-key h3 { font-size: 1rem; margin-top: 4px; }++.cn-key p { margin: 0; color: var(--ovk-muted); }++.cn-copy { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }++.cn-copy code { flex: 1 1 260px; min-width: 0; padding: 10px 12px; border-radius: 10px; background: var(--ovk-input); border: 1px solid var(--ovk-line); overflow-wrap: anywhere; user-select: all; }++.cn-steps { margin: 0; padding-left: 1.3em; display: grid; gap: 12px; }++.cn-steps li { display: grid; gap: 6px; }++.cn-steps li > span { color: var(--ovk-text); }++.cn-steps a { color: var(--room-strong); }++++.cn-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 0; }++.cn-facts div { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); min-width: 0; }++.cn-facts dt { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }++.cn-facts dd { margin: 4px 0 0; font-weight: 600; overflow-wrap: anywhere; }++.cn-run { display: grid; gap: 10px; }++.cn-run h2 { margin: 0; }++.cn-run a { color: var(--room-strong); }++.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }++.cn-visits span { color: var(--ovk-muted); font-size: 14px; }++.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }++.cn-policy h2 { margin: 0; }++.cn-danger { display: grid; gap: 12px; }++.cn-danger h2 { margin: 0; }++.cn-danger form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }++.cn-danger form span { font-size: 13.5px; color: var(--ovk-muted); flex: 1 1 220px; }++++/* On a phone the room's hero picture is a faint mark behind the heading; Connect's picture is a small diagram with++ words in it, so it sits under the hero text instead, at full strength (nothing behind the heading). */++@media (max-width: 759px) {++ .cn .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 4px; }++ .cn .cn-motif { width: 100%; }++}nnew file mode 100644@@@ -0,0 +1,231 @@++#!/usr/bin/env node++'use strict';++/**++ * ov-connect: put one port on your computer at your OpenVibe Connect address, behind OpenVibe sign-in.++ *++ * node ov-connect.js --key ovc_… --port 8080++ * OV_CONNECT_KEY=ovc_… node ov-connect.js 8080++ *++ * Needs Node 22 or newer and nothing else. It only ever opens connections to localhost on the port you give it: the++ * server cannot make it reach anything else. Your key is sent once per connection, over TLS, as the first message.++ *++ * --key <ovc_…> your share's connect key (or OV_CONNECT_KEY)++ * --port <n> the local port to share (or the first bare number)++ * --server <wss://> default wss://connect.openvibe.website/tunnel++ * --quiet no line per request++ *++ * Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0). Protocol: ovconnect-ws/1 (server/edge/frames.js).++ */++const http = require('http');++const net = require('net');++++const VERSION = '0.1.0';++const PROTOCOL_VERSION = 1;++const HIGH_WATER = 4 * 1024 * 1024;++const KIND = { DATA: 1, END: 2 };++++function parseArgs(argv) {++ const o = { key: process.env.OV_CONNECT_KEY || '', port: 0, server: process.env.OV_CONNECT_SERVER || 'wss://connect.openvibe.website/tunnel', quiet: false };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--key') o.key = argv[++i] || '';++ else if (a.startsWith('--key=')) o.key = a.slice(6);++ else if (a === '--port') o.port = Number(argv[++i]);++ else if (a.startsWith('--port=')) o.port = Number(a.slice(7));++ else if (a === '--server') o.server = argv[++i] || o.server;++ else if (a.startsWith('--server=')) o.server = a.slice(9);++ else if (a === '--quiet' || a === '-q') o.quiet = true;++ else if (a === '--help' || a === '-h') o.help = true;++ else if (a === '--version') o.version = true;++ else if (/^\d+$/.test(a) && !o.port) o.port = Number(a);++ }++ return o;++}++++function encode(kind, id, payload) {++ const body = payload ? Buffer.from(payload) : Buffer.alloc(0);++ const out = Buffer.allocUnsafe(5 + body.length);++ out.writeUInt8(kind, 0);++ out.writeUInt32BE(id >>> 0, 1);++ body.copy(out, 5);++ return out;++}++++function main() {++ const opts = parseArgs(process.argv.slice(2));++ if (opts.version) { console.log(`ov-connect ${VERSION}`); return; }++ if (opts.help || !opts.key || !opts.port) {++ console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.website');++ process.exitCode = opts.help ? 0 : 2;++ return;++ }++ if (typeof WebSocket !== 'function') { console.error('ov-connect needs Node 22 or newer (it uses the built-in WebSocket).'); process.exitCode = 2; return; }++ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) { console.error('The port is a number from 1 to 65535.'); process.exitCode = 2; return; }++ const client = createClient(opts);++ client.start();++ const stop = () => { client.stop(); setTimeout(() => process.exit(0), 300).unref(); };++ process.on('SIGINT', stop);++ process.on('SIGTERM', stop);++}++++/**++ * The client itself (exported for tests): createClient({ key, port, server, quiet, log, localHost }) → { start, stop, onReady }++ */++function createClient(opts) {++ const log = opts.log || ((line) => console.log(line));++ const localHost = opts.localHost || 'localhost';++ let ws = null;++ let stopped = false;++ let backoff = 1000;++ let readyShare = null;++ const streams = new Map(); // id → { kind: 'http'|'raw', req?, res?, sock? }++ const listeners = { ready: [] };++++ const sendJson = (o) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); };++ const sendData = (id, buf) => { if (ws && ws.readyState === 1 && buf.length) ws.send(encode(KIND.DATA, id, buf)); };++ const sendEnd = (id) => { if (ws && ws.readyState === 1) ws.send(encode(KIND.END, id)); };++ const congested = () => ws && ws.bufferedAmount > HIGH_WATER;++ function whenDrained(fn) { const tick = () => { if (!congested()) fn(); else setTimeout(tick, 10); }; setTimeout(tick, 10); }++ function abort(id, code, message) { sendJson({ t: 'abort', id, code, message: String(message || '').slice(0, 200) }); cleanup(id); }++ function cleanup(id) {++ const s = streams.get(id);++ if (!s) return;++ streams.delete(id);++ try { if (s.req) s.req.destroy(); } catch { /* gone */ }++ try { if (s.sock) s.sock.destroy(); } catch { /* gone */ }++ }++++ function onRequest(m) {++ const started = Date.now();++ const flat = [];++ for (const [k, v] of Array.isArray(m.headers) ? m.headers : []) flat.push(k, v);++ let req;++ try {++ req = http.request({ host: localHost, port: opts.port, method: m.method, path: m.path, headers: flat, setHost: false });++ } catch (err) {++ sendJson({ t: 'abort', id: m.id, code: 'local_error', message: String(err && err.code || 'bad request') });++ return;++ }++ const s = { kind: 'http', req, res: null };++ streams.set(m.id, s);++ req.on('response', (res) => {++ s.res = res;++ const headers = [];++ for (let i = 0; i + 1 < res.rawHeaders.length; i += 2) headers.push([res.rawHeaders[i], res.rawHeaders[i + 1]]);++ sendJson({ t: 'res', id: m.id, status: res.statusCode, headers });++ if (!opts.quiet) log(`${m.method} ${String(m.path).slice(0, 120)} → ${res.statusCode} (${Date.now() - started} ms)`);++ res.on('data', (chunk) => {++ sendData(m.id, chunk);++ if (congested()) { res.pause(); whenDrained(() => res.resume()); }++ });++ res.on('end', () => { sendEnd(m.id); streams.delete(m.id); });++ res.on('error', () => abort(m.id, 'local_error', 'the response broke off'));++ });++ req.on('error', (err) => {++ if (!streams.has(m.id)) return;++ const refused = err && (err.code === 'ECONNREFUSED' || err.code === 'EADDRNOTAVAIL');++ if (!opts.quiet) log(`${m.method} ${String(m.path).slice(0, 120)} → ${refused ? `nothing listening on port ${opts.port}` : err.message}`);++ abort(m.id, refused ? 'local_refused' : 'local_error', err && err.code);++ });++ req.on('drain', () => sendJson({ t: 'resume', id: m.id }));++ }++++ function onRaw(m) {++ const sock = net.connect({ host: localHost, port: opts.port });++ const s = { kind: 'raw', sock, connected: false, buffered: [] };++ streams.set(m.id, s);++ sock.on('connect', () => {++ s.connected = true;++ sock.write(String(m.head));++ for (const b of s.buffered) sock.write(b);++ s.buffered = [];++ if (s.ended) sock.end();++ });++ sock.on('data', (chunk) => {++ sendData(m.id, chunk);++ if (congested()) { sock.pause(); whenDrained(() => sock.resume()); }++ });++ sock.on('end', () => { sendEnd(m.id); });++ sock.on('close', () => { if (streams.has(m.id)) { streams.delete(m.id); sendEnd(m.id); } });++ sock.on('error', (err) => {++ const refused = err && err.code === 'ECONNREFUSED';++ abort(m.id, refused ? 'local_refused' : 'local_error', err && err.code);++ });++ sock.on('drain', () => sendJson({ t: 'resume', id: m.id }));++ if (!opts.quiet) log(`upgrade ${String(m.head).split('\r\n')[0].slice(0, 120)}`);++ }++++ function onBinary(buf) {++ if (buf.length < 5) return;++ const kind = buf.readUInt8(0);++ const id = buf.readUInt32BE(1);++ const s = streams.get(id);++ if (!s) return;++ if (kind === KIND.DATA) {++ const payload = buf.subarray(5);++ if (s.kind === 'http') { if (!s.req.write(payload)) sendJson({ t: 'pause', id }); }++ else if (!s.connected) s.buffered.push(payload);++ else if (!s.sock.write(payload)) sendJson({ t: 'pause', id });++ } else if (kind === KIND.END) {++ if (s.kind === 'http') s.req.end();++ else if (s.connected) s.sock.end();++ else s.ended = true;++ }++ }++++ function onControl(m) {++ if (m.t === 'ready') {++ backoff = 1000;++ readyShare = m.share;++ log(`Connected. ${m.share.url} → localhost:${opts.port} (only people you let in can open it)`);++ for (const fn of listeners.ready) fn(m.share);++ return;++ }++ if (m.t === 'error') { log(`The server refused: ${m.message}`); return; }++ if (m.t === 'req') return onRequest(m);++ if (m.t === 'raw') return onRaw(m);++ const s = Number.isInteger(m.id) ? streams.get(m.id) : null;++ if (!s) return;++ if (m.t === 'abort') cleanup(m.id);++ else if (m.t === 'pause') { if (s.res) s.res.pause(); if (s.sock) s.sock.pause(); }++ else if (m.t === 'resume') { if (s.res) s.res.resume(); if (s.sock) s.sock.resume(); }++ }++++ function connect() {++ if (stopped) return;++ ws = new WebSocket(opts.server);++ ws.binaryType = 'arraybuffer';++ ws.addEventListener('open', () => {++ ws.send(JSON.stringify({ t: 'hello', v: PROTOCOL_VERSION, key: opts.key, client: `ov-connect/${VERSION}`, port: opts.port }));++ });++ ws.addEventListener('message', (ev) => {++ if (typeof ev.data === 'string') {++ let m;++ try { m = JSON.parse(ev.data); } catch { return; }++ if (m && typeof m === 'object') onControl(m);++ } else onBinary(Buffer.from(ev.data));++ });++ ws.addEventListener('close', (ev) => {++ for (const id of [...streams.keys()]) cleanup(id);++ if (stopped) return;++ if (ev.code === 4001) { log('Another ov-connect took over this share. Stopping here.'); stopped = true; return; }++ if (ev.code === 4002 || ev.code === 4003) { log(`Stopped: ${ev.reason || 'the share or its key is no longer valid'}.`); stopped = true; process.exitCode = 1; return; }++ const wait = backoff;++ backoff = Math.min(backoff * 2, 30_000);++ log(`Disconnected${ev.reason ? ` (${ev.reason})` : ''}. Trying again in ${Math.round(wait / 1000)} s…`);++ setTimeout(connect, wait);++ });++ ws.addEventListener('error', () => { /* the close event follows */ });++ }++++ return {++ start() { stopped = false; connect(); },++ stop() { stopped = true; for (const id of [...streams.keys()]) cleanup(id); try { if (ws) ws.close(1000, 'bye'); } catch { /* gone */ } },++ onReady(fn) { listeners.ready.push(fn); if (readyShare) fn(readyShare); },++ get share() { return readyShare; },++ };++}++++if (require.main === module) main();++++module.exports = { createClient, parseArgs, encode, VERSION };nnew file mode 100644@@@ -0,0 +1,201 @@++'use strict';++++/**++ * OpenVibe.Connect: the Express app factory; server/index.js listens, tests build their own instance with a temp++ * database and a mock Network.++ *++ * <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else++ * /, /shares/*, /grant the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /ov-connect.js the client, served as it is in public/++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /internal/events account export/deletion (loopback only)++ * WebSocket /tunnel a computer connecting a share (edge/tunnels.js); ctx.onUpgrade routes it++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { assetVersion, setRelease } = require('./render/layout');++const { createShares } = require('./shares');++const { createTunnels } = require('./edge/tunnels');++const { createGate } = require('./edge/gate');++const { createProxy, rawResponder } = require('./edge/proxy');++++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 (Network), log, valkey, accountSend (a stand-in for Network's internal++ * routes; tests only), telemetrySink.++ */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const s = opts.store || await openStore(config, { now: opts.now, log });++++ const keys = createKeyStore({ config, fetchImpl, log });++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });++ const principal = createPrincipal({ config, keys });++ const shares = createShares(s, { config });++ const tunnels = createTunnels({ shares, config, log });++ const gate = createGate({ config, now: s.now });++ const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });++++ 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: Connect cannot answer account events'); });++++ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, accountData, accountSend, log };++++ /** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */++ ctx.onUpgrade = (req, socket, head) => {++ if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }++ if (String(req.url || '').split('?')[0] === '/tunnel') { tunnels.handleUpgrade(req, socket, head); return; }++ socket.on('error', () => {});++ const r = rawResponder(socket);++ r.writeHead(404, { 'content-type': 'text/plain' });++ r.end('No WebSocket here.');++ };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++++ // ── Shares first: a share host's every request belongs to the share (the gate, then the tunnel). Nothing else of++ // Connect (its pages, its API, its sign-in) answers on a share's address. ──++ app.use((req, res, next) => {++ if (!proxy.isShareHost(req.headers.host)) return next();++ return proxy.handle(req, res).catch((err) => {++ log.error('[OpenVibe.Connect] share request failed:', err && err.message);++ if (!res.headersSent) { res.statusCode = 502; res.end('Something went wrong on our side.'); } else res.destroy();++ });++ });++++ const release = require('openvibe-shared/release').createRelease({ service: 'connect', root: path.join(__dirname, '..') });++ setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'connect', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'connect', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });++ app.use(ctx.telemetry.middleware());++ app.locals.ctx = ctx;++++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ // The grant sends the browser on to a share's own address.++ 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-connect', name: 'OpenVibe Connect', version: VERSION, tunnels: tunnels.status() }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, tunnels });++ 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: 'connect', service: 'connect', host: 'connect.openvibe.website', name: 'OpenVibe Connect', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only; nginx answers 404 for /internal/) ──++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets (content-hashed ?v= → immutable); the client is a plain download ──++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.get('/ov-connect.js', (req, res) => {++ res.set('Cache-Control', 'public, max-age=300');++ res.type('application/javascript').sendFile(path.join(PUBLIC_DIR, 'ov-connect.js'));++ });++ 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.CONNECT_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.CONNECT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => sendNotFound(req, res, ctx));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Connect]', 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 };++}++++function sendNotFound(req, res, ctx) {++ const views = require('./views');++ const { send } = require('./render/layout');++ return send(res, 404, {++ viewer: req.viewer, config: ctx.config, path: req.originalUrl, title: 'Not found',++ body: views.notFound(), active: '/',++ });++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's Bearer token (the API).++ *++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves++ * this module: token.no_key answers 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Connect] 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.Connect] 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.Connect] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,232 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: openvibe-sdk/sso (0.73.0) is the one module every site uses. This file is this++ * site's thin wrapper around it — the routes, the middleware and the shapes this site's pages still read.++ *++ * GET /auth/login → Network /oauth/authorize with state and PKCE (S256); ?next, ?silent, ?prompt, ?login_hint++ * GET /auth/callback → the state is compared in constant time, the code is exchanged server-side (client secret)++ * GET /auth/logout → the cookies are cleared, the refresh token revoked (best effort)++ * GET /auth/me → { user } for the shared navbar++ * GET /auth/health → { ok, client }: does the Network accept this site's OAuth client?++ * POST /auth/fedcm → the shared navbar's FedCM assertion, swapped for tokens++ * POST /auth/refresh → the tokens are rotated with the refresh token++ *++ * The SDK recovers by itself once (a lost or expired flow, a code the Network no longer accepts) and shows a page++ * with "Try again" when it cannot: no more a bare "The Network refused the sign-in code." A client the Network++ * refuses is logged as a configuration error, and GET /auth/health answers 503 for it.++ *++ * The person's old cookie (connect_at) is read as the session until the next sign-in or refresh writes ov_token, so++ * nobody is signed out by the move. The access token stays httpOnly: no script can read it (the navbar asks++ * /auth/me). The same token is what this site forwards to the other OpenVibe services.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { createSsoClient, COOKIES, parseCookies, sanitizeNext } = require('openvibe-sdk/sso');++++const ACCESS_COOKIE = 'connect_at';++const REFRESH_COOKIE = 'connect_rt';++const HINT_COOKIE = 'ov_sso_hint';++++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;++}++++/** The authorize URL for a sign-in (pure; the SDK builds its own, this one stays for the site's tests). */++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 }) {++ // The one module. The site's own values, its own old cookies, and the audience it always verified with.++ const sso = createSsoClient({++ site: 'connect',++ siteName: 'OpenVibe Connect',++ baseUrl: config.baseUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ networkUrl: config.networkPublicUrl || config.networkUrl,++ networkInternalUrl: config.networkInternalUrl || config.networkUrl,++ issuer: config.networkIssuer || config.networkPublicUrl || config.networkUrl,++ redirectUri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ audience: config.oauth.sessionAudience,++ secureCookies: config.cookies.secure !== false,++ httpOnlyAccess: true,++ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },++ fetch: fetchImpl,++ log,++ });++++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/' });++ const nextOf = (value) => sanitizeNext(value, { baseUrl: config.baseUrl, networkUrl: config.networkUrl });++++ /** The person, in the shape this site's pages read (req.viewer). */++ function viewerFrom(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ const username = typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++ return {++ kind: 'user',++ subject,++ username,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || '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,++ };++ }++++ /** The public profile the navbar and the API read. */++ const meOf = (claims) => ({++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ display_name: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : null,++ subject_id: typeof claims.subject_id === 'string' ? claims.subject_id : null,++ role: typeof claims.role === 'string' ? claims.role : 'user',++ avatar_url: typeof claims.avatar_url === 'string' ? claims.avatar_url.slice(0, 500) : null,++ profile_color: typeof claims.profile_color === 'string' ? claims.profile_color.slice(0, 16) : null,++ });++++ /**++ * The access token expired (or is gone) but the refresh token is still there: rotate it and write the SDK's own++ * cookies, the same names and options the SDK uses. The old cookies are cleared.++ */++ async function refreshSession(req, res) {++ const c = parseCookies(req);++ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];++ if (!rt) return null;++ let data = null;++ try { data = await sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: rt }); } catch { return null; }++ if (!data || !data.access_token) return null;++ const claims = await sso.verify(data.access_token);++ if (!claims) return null;++ res.cookie(COOKIES.access, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(COOKIES.refresh, data.refresh_token, { ...cookieBase(), path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.clearCookie(ACCESS_COOKIE, { path: '/' });++ res.clearCookie(REFRESH_COOKIE, { path: '/' });++ return { claims, token: data.access_token };++ }++++ /** req.user and req.token for a valid session; req.viewer keeps this site's pages working as they are. */++ function middleware() {++ const optional = sso.optionalAuth();++ return (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ optional(req, res, async () => {++ try {++ if (req.user && req.token) { req.viewer = viewerFrom(req.user, req.token); return next(); }++ const fresh = await refreshSession(req, res);++ if (fresh) {++ req.user = fresh.claims;++ req.token = fresh.token;++ req.viewer = viewerFrom(fresh.claims, fresh.token);++ }++ } catch (err) {++ log.warn('[OpenVibe.Connect] session refresh failed:', (err && err.message) || err);++ }++ return next();++ });++ };++ }++++ /** A call with the person's own token; a 401 refreshes the session once and tries again. */++ async function asViewer(req, res, fn) {++ const token = req.token || (req.viewer && req.viewer.token) || null;++ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401 });++ try {++ return await fn(token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refreshSession(req, res);++ if (!fresh) throw err;++ req.user = fresh.claims;++ req.token = fresh.token;++ req.viewer = viewerFrom(fresh.claims, fresh.token);++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++++ // GET /auth/me: the shared navbar's answer. A guest is { user: null } (200); a session that does not verify++ // is 401, as it always was here.++ r.get('/me', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const c = parseCookies(req);++ const presented = Boolean(c[COOKIES.access] || c[ACCESS_COOKIE] || c[COOKIES.refresh] || c[REFRESH_COOKIE] || req.get('authorization'));++ if (!presented) return res.json({ user: null });++ const claims = await sso.verify(sso.extractToken(req));++ if (!claims) return res.status(401).json({ error: 'Not signed in' });++ return res.json({ user: meOf(claims) });++ });++++ // Sign-out stays a same-site action: another site cannot sign a person out.++ 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(nextOf(req.query.next))}` : '';++ 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.Connect?</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) => {++ // Any other verb (a DELETE from another site) is refused like the POST is.++ if (!sameOriginRequest(req)) return res.status(403).type('text/plain').send('Sign-out must come from this site.');++ const c = parseCookies(req);++ const rt = c[COOKIES.refresh] || c[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(() => {});++ }++ res.clearCookie(COOKIES.access, { path: '/' });++ res.clearCookie(COOKIES.refresh, { path: '/auth' });++ res.clearCookie(ACCESS_COOKIE, { path: '/' });++ res.clearCookie(REFRESH_COOKIE, { path: '/' });++ res.set('Clear-Site-Data', '"cache"');++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ return res.redirect(303, nextOf(req.query.next));++ });++++ // The SDK's own routes: /login, /callback, /fedcm, /refresh, /health.++ r.use(sso.router(express));++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };++}++++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * OpenVibe.Connect configuration (connect.openvibe.website). Every value comes from the environment (production:++ * /etc/openvibe/connect.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are++ * never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++const bool = (v, def = false) => (v == null ? def : v === '1' || v === 'true' || v === 'yes');++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5240);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.website' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'connect',++ room: 'website',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: bool(env.CONNECT_DRILL),++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CONNECT_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:connect:' },++++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ audience: env.CONNECT_AUDIENCE || 'openvibe.website',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'connect',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's account export/deletion route (comma-separated for rotation).++ events: {++ secrets: String(env.CONNECT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.CONNECT_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.CONNECT_EVENTS_ENDPOINT || '',++ },++++ // The shares. A share answers at https://<label>--<user>.<shareDomain>. The session secret signs the++ // visitor's host-only cookie on a share host and is required in production (32+ characters).++ shares: {++ domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.website').toLowerCase(),++ scheme: env.CONNECT_SHARE_SCHEME || (isProduction ? 'https' : 'http'),++ sessionSecret: env.CONNECT_SESSION_SECRET || (isProduction ? '' : 'development-only-session-secret-0123456789'),++ sessionHours: Math.max(1, Math.min(72, int(env.CONNECT_SESSION_HOURS, 12))),++ perPerson: Math.max(1, Math.min(50, int(env.CONNECT_SHARES_PER_PERSON, 5))),++ // Per share: requests at once, a request body's size, and how long a request may take end to end.++ maxStreams: Math.max(1, Math.min(1000, int(env.CONNECT_MAX_STREAMS, 64))),++ maxBodyBytes: Math.max(1024, int(env.CONNECT_MAX_BODY_BYTES, 100 * 1024 * 1024)),++ requestTimeoutMs: Math.max(1000, int(env.CONNECT_REQUEST_TIMEOUT_MS, 120_000)),++ // A share's requests per minute, and the bytes a person's shares may move in a day (both directions).++ perMinute: Math.max(1, int(env.CONNECT_REQUESTS_PER_MINUTE, 1200)),++ bytesPerDay: Math.max(1024 * 1024, int(env.CONNECT_BYTES_PER_DAY, 10 * 1024 * 1024 * 1024)),++ },++ // The tunnel: how often the server pings an idle tunnel (Cloudflare closes a WebSocket idle for 100 s), and++ // how long a client has to say hello.++ tunnel: {++ pingMs: Math.max(1000, int(env.CONNECT_PING_MS, 25_000)),++ helloMs: Math.max(500, int(env.CONNECT_HELLO_MS, 10_000)),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Connect's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret: a share stores its owner's subject id, its address, its port and the hash++ * of its connect key; a visit row is a count per share, person and day.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Connect] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'connect', 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: 'connect-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'connect', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx((t) => fn(t)),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++++/**++ * The tunnel's wire format, `ovconnect-ws/1`: one WebSocket per connected share, many requests at once over it.++ *++ * Text frames are JSON control messages:++ * client → server { t: 'hello', key, v: 1, client, port }++ * server → client { t: 'ready', v: 1, share: { id, host, url, mode } } or { t: 'error', code, message } then close++ * server → client { t: 'req', id, method, path, headers: [[name, value], …] } an HTTP request++ * server → client { t: 'raw', id, head } a WebSocket upgrade: the client++ * opens a plain TCP connection to the local port, writes `head`, and from then on both sides++ * only move bytes (the local server answers the 101 itself)++ * client → server { t: 'res', id, status, headers: [[name, value], …] } the response head++ * either way { t: 'pause', id } / { t: 'resume', id } backpressure on one stream++ * either way { t: 'abort', id, code, message } the stream is over, badly++ *++ * Binary frames carry bodies: [kind: 1 byte][stream id: 4 bytes, big-endian][payload].++ * kind 1 data kind 2 end (no payload)++ *++ * Stream ids are chosen by the server (1, 2, 3 …) and never reused on one tunnel.++ */++const KIND = { DATA: 1, END: 2 };++const VERSION = 1;++const PROTOCOL = 'ovconnect-ws/1';++++function encode(kind, id, payload) {++ const body = payload ? (Buffer.isBuffer(payload) ? payload : Buffer.from(payload)) : Buffer.alloc(0);++ const out = Buffer.allocUnsafe(5 + body.length);++ out.writeUInt8(kind, 0);++ out.writeUInt32BE(id >>> 0, 1);++ body.copy(out, 5);++ return out;++}++++function decode(buf) {++ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);++ if (b.length < 5) return null;++ const kind = b.readUInt8(0);++ if (kind !== KIND.DATA && kind !== KIND.END) return null;++ return { kind, id: b.readUInt32BE(1), payload: b.subarray(5) };++}++++/** Header pairs as sent on the wire: lowercase names, string values, nothing longer than 16 KB in all. */++function headerPairs(rawHeaders) {++ const out = [];++ let size = 0;++ for (let i = 0; i + 1 < rawHeaders.length; i += 2) {++ const name = String(rawHeaders[i]).toLowerCase();++ const value = String(rawHeaders[i + 1]);++ size += name.length + value.length;++ if (size > 64 * 1024) break;++ out.push([name, value]);++ }++ return out;++}++++module.exports = { KIND, VERSION, PROTOCOL, encode, decode, headerPairs };nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++++/**++ * The gate in front of every share: who may open it, and how a visitor's browser proves it on a share host.++ *++ * A share host is another origin (agents--alex.openvibe.website), and openvibe.website is not on the Public Suffix++ * List yet, so the gate keeps every cookie host-only and signs it itself:++ *++ * 1. A visitor without a valid cookie is sent to https://connect.openvibe.website/grant?share=<id>&next=<path>.++ * 2. There, signed in to OpenVibe (sign-in happens first if needed), the share's policy is checked. A person it lets++ * in gets a one-time code (60 seconds, used once, bound to that share) and goes back to++ * https://<share host>/__ovc/callback?code=…&next=….++ * 3. The share host swaps the code for its own cookie: `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no++ * Domain), an HMAC-signed { share, subject, username, policy version, expiry }.++ *++ * A policy change bumps the share's policy_version, so every cookie made before it stops working on the next request++ * and its holder goes through the grant again (the owner never notices: they are let straight back in).++ */++const crypto = require('crypto');++++const CODE_TTL_MS = 60_000;++++function createGate({ config, now = () => Date.now() }) {++ const secret = config.shares.sessionSecret;++ if (!secret || secret.length < 32) throw new Error('CONNECT_SESSION_SECRET must be set (32+ characters): it signs the visitors\' cookies on share hosts');++ const cookieName = config.cookies.secure !== false ? '__Host-ovc' : 'ovc';++ const codes = new Map(); // code → { shareId, subject, username, exp }++++ const sweeper = setInterval(() => {++ const t = now();++ for (const [k, v] of codes) if (v.exp <= t) codes.delete(k);++ }, 30_000);++ sweeper.unref();++++ const mac = (data) => crypto.createHmac('sha256', secret).update(data).digest('base64url');++++ function sign({ shareId, subject, username, policyVersion }) {++ const exp = now() + config.shares.sessionHours * 3600_000;++ const data = Buffer.from(JSON.stringify({ s: shareId, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');++ return `${data}.${mac(data)}`;++ }++++ /** The cookie's claims when it is ours, unexpired and for this share at this policy version; else null. */++ function verify(value, share) {++ if (typeof value !== 'string' || value.length > 2048) return null;++ const dot = value.lastIndexOf('.');++ if (dot <= 0) return null;++ const data = value.slice(0, dot);++ const sig = value.slice(dot + 1);++ const want = mac(data);++ if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;++ let c;++ try { c = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { return null; }++ if (!c || c.s !== share.id || c.v !== share.policy_version || !(c.e > now())) return null;++ return { subject: c.u, username: c.n };++ }++++ function cookieHeader(value) {++ const maxAge = config.shares.sessionHours * 3600;++ return `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.cookies.secure !== false ? '; Secure' : ''}`;++ }++ const clearCookieHeader = () => `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${config.cookies.secure !== false ? '; Secure' : ''}`;++++ /** Our cookie's value from a Cookie header, and the header without it (what the local app receives). */++ function splitCookies(header) {++ let ours = null;++ const rest = [];++ for (const part of String(header || '').split(';')) {++ const p = part.trim();++ if (!p) continue;++ const eq = p.indexOf('=');++ const name = eq < 0 ? p : p.slice(0, eq);++ if (name === cookieName || name === '__Host-ovc' || name === 'ovc') { if (name === cookieName) ours = eq < 0 ? '' : p.slice(eq + 1); continue; }++ rest.push(p);++ }++ return { ours, rest: rest.join('; ') };++ }++++ function mintCode({ shareId, subject, username }) {++ const code = crypto.randomBytes(24).toString('base64url');++ codes.set(code, { shareId, subject, username, exp: now() + CODE_TTL_MS });++ return code;++ }++ function redeemCode(code, shareId) {++ if (typeof code !== 'string') return null;++ const v = codes.get(code);++ if (!v) return null;++ codes.delete(code);++ if (v.exp <= now() || v.shareId !== shareId) return null;++ return v;++ }++++ /** A path on the share host to go back to: a local path only (never another host, never "//"). */++ function safeNext(next) {++ const s = typeof next === 'string' ? next : '/';++ if (!s.startsWith('/') || s.startsWith('//') || s.startsWith('/\\') || /[\r\n\t]/.test(s) || s.length > 2000) return '/';++ if (s.startsWith('/__ovc/')) return '/';++ return s;++ }++++ return { sign, verify, cookieHeader, clearCookieHeader, splitCookies, mintCode, redeemCode, safeNext, cookieName, stop: () => clearInterval(sweeper), CODE_TTL_MS };++}++++module.exports = { createGate, CODE_TTL_MS };nnew file mode 100644@@@ -0,0 +1,384 @@++'use strict';++++/**++ * The visitor's side of a share: every request to https://<label>--<user>.openvibe.website.++ *++ * handle(req, res) an HTTP request (SSE and streaming bodies included)++ * upgrade(req, socket, head) a WebSocket upgrade: after the gate, a byte pipe to the local port++ * isShareHost(host) is this host a share's (the naming rule only)++ * flush() write the counters gathered in memory (every 30 s, and on stop)++ *++ * The share comes from the Host header nginx sets ($host), never from X-Forwarded-Host or any other header a++ * visitor controls. The local app receives the request with Host `localhost:<port>`, the share's host in++ * X-Forwarded-Host, the visitor's address in X-Forwarded-For, and who they are in X-OpenVibe-User and++ * X-OpenVibe-Subject (headers of those names from the visitor are dropped). The gate's own cookie never reaches it.++ *++ * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.website is on the Public Suffix List, a++ * cookie for .openvibe.website would reach every other share and every Space site), and nothing a share sends may be++ * kept by Cloudflare's cache (Cloudflare-CDN-Cache-Control: no-store; Cache-Control is made private): a cached copy++ * would be served past the gate.++ */++const { parseHost } = require('../shares');++++const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade']);++const DROP_IN = /^(host|cookie|forwarded|x-forwarded-.*|x-real-ip|x-openvibe-.*|cf-.*|cdn-loop|true-client-ip)$/;++const SHARE_CACHE_MS = 5000;++++/**++ * The app's Cache-Control, made safe to pass a CDN: `public` and `s-maxage` are dropped and `private` is added, so++ * the browser may still keep what the app says it may, and no shared cache ever does. No header → `private, no-cache`.++ */++function privateCache(value) {++ const parts = String(value || '').split(',').map((p) => p.trim()).filter(Boolean)++ .filter((p) => !/^(public|s-maxage\s*=.*|proxy-revalidate)$/i.test(p));++ if (!parts.length) return 'private, no-cache';++ if (parts.some((p) => /^(private|no-store)$/i.test(p))) return parts.join(', ');++ return ['private', ...parts].join(', ');++}++++function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {++ const domain = config.shares.domain;++ const cache = new Map(); // host → { share, at }++ const windows = new Map(); // share id → { start, count }++ const ownerBytes = new Map(); // owner subject → { day, bytes }++ let counters = {}; // share id → { requests, bytesIn, bytesOut, visits }++++ const isShareHost = (host) => Boolean(parseHost(host, domain));++ const today = () => new Date(now()).toISOString().slice(0, 10);++ const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;++++ async function lookup(host) {++ const hit = cache.get(host);++ if (hit && now() - hit.at < SHARE_CACHE_MS) return hit.share;++ const share = await shares.byHost(host);++ cache.set(host, { share, at: now() });++ if (cache.size > 5000) cache.delete(cache.keys().next().value);++ return share;++ }++ /** Forget what is cached for a share (a policy change, a new key, a revoke). */++ function forget(host) { cache.delete(String(host || '').toLowerCase()); }++++ function counter(shareId) {++ if (!counters[shareId]) counters[shareId] = { requests: 0, bytesIn: 0, bytesOut: 0, visits: {} };++ return counters[shareId];++ }++ function visit(share, who, { denied = false } = {}) {++ const c = counter(share.id);++ if (!denied) c.requests++;++ if (!who || !who.subject) return;++ const v = c.visits[who.subject] || (c.visits[who.subject] = { username: who.username || null, requests: 0, denied: 0 });++ if (denied) v.denied++; else v.requests++;++ }++ function addBytes(share, dir, n) {++ if (!n) return;++ const c = counter(share.id);++ if (dir === 'in') c.bytesIn += n; else c.bytesOut += n;++ const d = today();++ const o = ownerBytes.get(share.owner_subject);++ if (!o || o.day !== d) ownerBytes.set(share.owner_subject, { day: d, bytes: n });++ else o.bytes += n;++ }++ const overBytes = (share) => { const o = ownerBytes.get(share.owner_subject); return Boolean(o && o.day === today() && o.bytes >= config.shares.bytesPerDay); };++ function overRate(share) {++ const t = now();++ const w = windows.get(share.id);++ if (!w || t - w.start >= 60_000) { windows.set(share.id, { start: t, count: 1 }); return false; }++ w.count++;++ return w.count > config.shares.perMinute;++ }++++ async function flush() {++ const batch = counters;++ counters = {};++ if (!Object.keys(batch).length) return;++ try { await shares.flushCounters(batch, today()); } catch (err) { log.warn('[OpenVibe.Connect] counters not written:', err.message); }++ }++ const flusher = setInterval(() => { flush().catch(() => {}); }, 30_000);++ flusher.unref();++++ function forwardHeaders(req, share, who, port, { raw = false } = {}) {++ const out = [];++ const pairs = req.rawHeaders;++ for (let i = 0; i + 1 < pairs.length; i += 2) {++ const name = String(pairs[i]).toLowerCase();++ if (DROP_IN.test(name)) continue;++ if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;++ out.push([name, String(pairs[i + 1])]);++ }++ const { rest } = gate.splitCookies(req.headers.cookie);++ if (rest) out.push(['cookie', rest]);++ out.push(['host', `localhost:${port}`]);++ out.push(['x-forwarded-host', share.host]);++ out.push(['x-forwarded-proto', config.shares.scheme]);++ const ip = String(req.headers['x-real-ip'] || req.socket.remoteAddress || '').slice(0, 64);++ if (ip) out.push(['x-forwarded-for', ip]);++ out.push(['x-openvibe-user', String(who.username || '')]);++ out.push(['x-openvibe-subject', String(who.subject || '')]);++ return out;++ }++++ function responseHeaders(pairs) {++ const out = {};++ const cookies = [];++ let cacheControl = null;++ for (const pair of Array.isArray(pairs) ? pairs : []) {++ if (!Array.isArray(pair) || pair.length !== 2) continue;++ const name = String(pair[0]).toLowerCase();++ const value = String(pair[1]);++ if (!/^[!#$%&'*+.^_`|~0-9a-z-]{1,100}$/.test(name) || /[\r\n\0]/.test(value)) continue;++ if (HOP.has(name) || name === 'cloudflare-cdn-cache-control' || name === 'cdn-cache-control' || name === 'x-robots-tag') continue;++ if (name === 'set-cookie') {++ const c = scrubCookie(value);++ if (c) cookies.push(c);++ continue;++ }++ if (name === 'cache-control') { cacheControl = value; continue; }++ if (out[name] === undefined) out[name] = value;++ else out[name] = Array.isArray(out[name]) ? [...out[name], value] : [out[name], value];++ }++ if (cookies.length) out['set-cookie'] = cookies;++ out['cache-control'] = privateCache(cacheControl);++ out['cloudflare-cdn-cache-control'] = 'no-store';++ out['x-robots-tag'] = 'noindex, nofollow';++ return out;++ }++++ function scrubCookie(value) {++ const parts = value.split(';');++ const name = parts[0].split('=')[0].trim();++ if (name === gate.cookieName || name === '__Host-ovc' || name === 'ovc') return null;++ return parts.filter((p, i) => i === 0 || !/^\s*domain\s*=/i.test(p)).join(';');++ }++++ // ── Pages the gate answers itself (self-contained: a share host loads nothing from anywhere else) ──++ function sendPage(res, status, title, text, { link = null, headers = {} } = {}) {++ if (res.headersSent) { res.destroy(); return; }++ res.writeHead(status, {++ 'content-type': 'text/html; charset=utf-8',++ 'cache-control': 'no-store',++ 'cloudflare-cdn-cache-control': 'no-store',++ 'x-robots-tag': 'noindex, nofollow',++ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",++ 'x-content-type-options': 'nosniff',++ 'referrer-policy': 'no-referrer',++ ...headers,++ });++ res.end(page(title, text, link));++ }++ function wantsHtml(req) { return /text\/html/.test(String(req.headers.accept || '')) && (req.method === 'GET' || req.method === 'HEAD'); }++ function sendProblem(req, res, status, code, title, text, extra = {}) {++ if (wantsHtml(req)) return sendPage(res, status, title, text, extra);++ if (res.headersSent) { res.destroy(); return undefined; }++ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'x-robots-tag': 'noindex', ...(extra.headers || {}) });++ res.end(JSON.stringify({ error: code, message: text, ...(extra.json || {}) }));++ return undefined;++ }++++ /** The share, the person and the tunnel for a request, or an answer already sent. */++ async function admit(req, res, share, parsedPath) {++ const { ours } = gate.splitCookies(req.headers.cookie);++ const who = ours ? gate.verify(ours, share) : null;++ if (!who) {++ const url = grantUrl(share, parsedPath);++ if (wantsHtml(req)) { res.writeHead(302, { location: url, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store' }); res.end(); return null; }++ sendProblem(req, res, 401, 'sign_in_required', 'Sign in first', 'This share asks every visitor to sign in with OpenVibe.', { json: { sign_in: url } });++ return null;++ }++ if (overRate(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'rate_limited', 'Too many requests', 'This share had too many requests in the last minute. Try again shortly.', { headers: { 'retry-after': '30' } }); return null; }++ if (overBytes(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'over_daily_limit', 'Over today\'s limit', `${share.owner_username}'s shares moved as much data as they can today. They open again at midnight UTC.`); return null; }++ const tunnel = tunnels.get(share.id);++ if (!tunnel) {++ sendProblem(req, res, 502, 'offline', `${share.owner_username}'s computer is not connected`, `Nothing is connected to ${share.host} right now. If it is yours, start ov-connect on your computer.`, { link: { href: `${config.baseUrl}/shares/${share.id}`, text: 'Open the share page' }, headers: { 'retry-after': '5' } });++ return null;++ }++ if (tunnel.busy) { sendProblem(req, res, 503, 'busy', 'Busy', 'This share is answering as many requests as it can at once. Try again in a moment.', { headers: { 'retry-after': '2' } }); return null; }++ return { who, tunnel };++ }++++ async function resolve(req, res) {++ const parsed = parseHost(req.headers.host, domain);++ if (!parsed) return null;++ let share;++ try { share = await lookup(parsed.host); } catch (err) {++ log.warn('[OpenVibe.Connect] share lookup failed:', err.message);++ sendProblem(req, res, 503, 'unavailable', 'Try again in a moment', 'OpenVibe Connect could not look this share up just now.');++ return null;++ }++ if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }++ if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }++ return share;++ }++++ async function handle(req, res) {++ const share = await resolve(req, res);++ if (!share) return;++ const url = String(req.url || '/');++ const path = url.split('?')[0];++++ if (path === '/__ovc/callback') {++ const q = new URL(url, 'http://x').searchParams;++ const v = gate.redeemCode(q.get('code'), share.id);++ if (!v) return sendPage(res, 400, 'That link ran out', 'Sign-in links work once, for a minute.', { link: { href: grantUrl(share, q.get('next') || '/'), text: 'Try again' } });++ const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version });++ res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });++ return res.end();++ }++ if (path === '/__ovc/signout') {++ return sendPage(res, 200, 'Signed out of this share', `You are signed out of ${share.host}. Your OpenVibe sign-in is untouched.`, { headers: { 'set-cookie': gate.clearCookieHeader() } });++ }++++ const admitted = await admit(req, res, share, url);++ if (!admitted) return;++ const { who, tunnel } = admitted;++ visit(share, who);++++ const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;++ let finished = false;++ let headTimer = null;++ let received = 0;++ const id = tunnel.open({++ onRes(m) {++ clearTimeout(headTimer);++ const status = Number(m.status);++ if (!Number.isInteger(status) || status < 100 || status > 599 || status === 101) {++ tunnel.abort(id, 'bad_status');++ return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');++ }++ try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }++ return undefined;++ },++ onData(buf) {++ addBytes(share, 'out', buf.length);++ if (!res.write(buf)) {++ tunnel.send({ t: 'pause', id });++ res.once('drain', () => tunnel.send({ t: 'resume', id }));++ }++ },++ onEnd() { finished = true; tunnel.release(id); clearTimeout(headTimer); res.end(); },++ onAbort(m) {++ finished = true;++ clearTimeout(headTimer);++ if (res.headersSent) { res.destroy(); return; }++ if (m && m.code === 'local_refused') {++ sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);++ } else if (m && m.code === 'tunnel_closed') {++ sendProblem(req, res, 502, 'offline', 'The connection dropped', `${share.owner_username}'s computer disconnected while answering. Try again in a moment.`, { headers: { 'retry-after': '5' } });++ } else {++ sendProblem(req, res, 502, 'local_error', 'The app did not answer', 'The app on the other end stopped before it answered.');++ }++ },++ onPause() { req.pause(); },++ onResume() { req.resume(); },++ });++ headTimer = setTimeout(() => {++ if (res.headersSent || finished) return;++ finished = true;++ tunnel.abort(id, 'timeout');++ sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);++ }, config.shares.requestTimeoutMs);++ headTimer.unref();++++ tunnel.send({ t: 'req', id, method: req.method, path: url, headers: forwardHeaders(req, share, who, port) });++ req.on('data', (chunk) => {++ received += chunk.length;++ addBytes(share, 'in', chunk.length);++ if (received > config.shares.maxBodyBytes) {++ if (!finished) { finished = true; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }++ req.destroy();++ return;++ }++ tunnel.data(id, chunk);++ if (tunnel.congested) { req.pause(); tunnel.drained().then(() => req.resume()); }++ });++ req.on('end', () => { if (!finished) tunnel.end(id); });++ res.on('close', () => { if (!finished && !res.writableFinished) { finished = true; clearTimeout(headTimer); tunnel.abort(id, 'visitor_gone'); } });++ }++++ /** A WebSocket upgrade on a share host: the gate, then the raw bytes both ways. */++ async function upgrade(req, socket, head) {++ socket.on('error', () => {});++ // A tiny response object over the raw socket, so the gate's answers read the same here.++ const fake = rawResponder(socket);++ const share = await resolve(req, fake);++ if (!share) return;++ const admitted = await admit(req, fake, share, String(req.url || '/'));++ if (!admitted) return;++ const { who, tunnel } = admitted;++ visit(share, who);++ const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;++++ const lines = [`${req.method} ${req.url} HTTP/1.1`];++ for (const [k, v] of forwardHeaders(req, share, who, port, { raw: true })) lines.push(`${k}: ${v.replace(/[\r\n]/g, ' ')}`);++ const headText = `${lines.join('\r\n')}\r\n\r\n`;++ let opened = false;++ const id = tunnel.open({++ onData(buf) {++ opened = true;++ addBytes(share, 'out', buf.length);++ if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }++ },++ onEnd() { tunnel.release(id); socket.end(); },++ onAbort(m) {++ if (!opened && m && m.code === 'local_refused') {++ socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);++ } else socket.destroy();++ },++ onPause() { socket.pause(); },++ onResume() { socket.resume(); },++ });++ tunnel.send({ t: 'raw', id, head: headText });++ if (head && head.length) { addBytes(share, 'in', head.length); tunnel.data(id, head); }++ socket.on('data', (chunk) => {++ addBytes(share, 'in', chunk.length);++ tunnel.data(id, chunk);++ if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }++ });++ socket.on('end', () => tunnel.end(id));++ socket.on('close', () => { if (tunnel.streams.has(id)) tunnel.abort(id, 'visitor_gone'); });++ }++++ function stop() { clearInterval(flusher); return flush(); }++++ /** The grant refused someone: counted on the share page as "turned away". */++ function denied(share, who) { visit(share, who, { denied: true }); }++++ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders };++}++++/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */++function rawResponder(socket) {++ let sent = false;++ let status = 200;++ let headers = {};++ return {++ get headersSent() { return sent; },++ writeHead(s, h) { status = s; headers = h || {}; sent = true; },++ end(body = '') {++ const lines = [`HTTP/1.1 ${status} ${STATUS_TEXT[status] || 'Error'}`];++ const b = Buffer.from(String(body));++ for (const [k, v] of Object.entries(headers)) for (const one of [].concat(v)) lines.push(`${k}: ${one}`);++ lines.push(`content-length: ${b.length}`, 'connection: close');++ socket.end(Buffer.concat([Buffer.from(`${lines.join('\r\n')}\r\n\r\n`), b]));++ },++ destroy() { socket.destroy(); },++ };++}++const STATUS_TEXT = { 302: 'Found', 400: 'Bad Request', 401: 'Unauthorized', 404: 'Not Found', 410: 'Gone', 429: 'Too Many Requests', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout' };++++const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** The gate's own page: plain, readable in both themes, no outside resources. */++function page(title, text, link) {++ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++<meta name="robots" content="noindex"><title>${escapeHtml(title)} · OpenVibe Connect</title>++<style>:root{color-scheme:light dark;--bg:#f6f7fb;--fg:#141824;--muted:#5b6275;--card:#fff;--line:#dfe3ee;--accent:#4B53C2}++@media (prefers-color-scheme:dark){:root{--bg:#0d131d;--fg:#e8ecf5;--muted:#9aa3b8;--card:#141c2a;--line:#253047;--accent:#8EA0FF}}++body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:24px max(16px,env(safe-area-inset-left))}++main{max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:28px}++h1{font-size:1.35rem;margin:0 0 .5rem;text-wrap:balance}p{margin:0 0 1rem;color:var(--muted)}++a{display:inline-block;color:var(--accent);font-weight:600;text-decoration:none;padding:10px 0;min-height:24px}a:hover{text-decoration:underline}++.mark{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}</style></head>++<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}</main></body></html>`;++}++++module.exports = { createProxy, page, rawResponder };nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++++/**++ * The tunnels: one WebSocket per connected share, opened by the client (public/ov-connect.js) to++ * wss://connect.openvibe.website/tunnel and authenticated by its first message, { t: 'hello', key }.++ *++ * createTunnels({ shares, config, log }) → { handleUpgrade(req, socket, head), get(shareId), close(shareId, code, why),++ * closeAll(), status() }++ * tunnel.open({ onRes, onData, onEnd, onAbort, onPause, onResume }) → stream id++ * tunnel.send(obj), tunnel.data(id, buf), tunnel.end(id), tunnel.abort(id, code, message), tunnel.release(id)++ *++ * A newer connection for the same share replaces the older one (close code 4001), so a laptop that reconnects after++ * sleep never leaves a dead tunnel behind. A wrong key is 4002; a revoked share or a rotated key closes its tunnel++ * (4003); a tunnel that stops answering pings is 4008. The server++ * pings every CONNECT_PING_MS so Cloudflare does not close an idle tunnel, and a tunnel that misses two pings is++ * closed.++ */++const { WebSocketServer } = require('ws');++const frames = require('./frames');++++const HIGH_WATER = 4 * 1024 * 1024;++++class Tunnel {++ constructor(ws, share, { log, maxStreams }) {++ this.ws = ws;++ this.share = share;++ this.log = log;++ this.maxStreams = maxStreams;++ this.streams = new Map();++ this.nextId = 1;++ this.connectedAt = Date.now();++ this.alive = true;++ this.closed = false;++ this.clientInfo = null;++ }++++ get busy() { return this.streams.size >= this.maxStreams; }++ get congested() { return this.ws.bufferedAmount > HIGH_WATER; }++++ open(handlers) {++ const id = this.nextId++;++ this.streams.set(id, handlers);++ return id;++ }++ release(id) { this.streams.delete(id); }++++ send(obj) { if (!this.closed && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj)); }++ data(id, buf) { if (!this.closed && this.ws.readyState === 1 && buf && buf.length) this.ws.send(frames.encode(frames.KIND.DATA, id, buf)); }++ end(id) { if (!this.closed && this.ws.readyState === 1) this.ws.send(frames.encode(frames.KIND.END, id)); }++ abort(id, code = 'aborted', message = '') {++ this.send({ t: 'abort', id, code, message: String(message).slice(0, 200) });++ this.release(id);++ }++++ /** Wait until the socket's send buffer drains below the high-water mark (or the tunnel closes). */++ drained() {++ if (!this.congested || this.closed) return Promise.resolve();++ return new Promise((resolve) => {++ const tick = () => { if (!this.congested || this.closed) resolve(); else setTimeout(tick, 10); };++ setTimeout(tick, 10);++ });++ }++++ onMessage(message, isBinary) {++ if (isBinary) {++ const f = frames.decode(message);++ if (!f) return;++ const h = this.streams.get(f.id);++ if (!h) return;++ if (f.kind === frames.KIND.DATA) { if (h.onData) h.onData(f.payload); } else if (h.onEnd) h.onEnd();++ return;++ }++ let m;++ try { m = JSON.parse(String(message)); } catch { return; }++ if (!m || typeof m !== 'object') return;++ const h = Number.isInteger(m.id) ? this.streams.get(m.id) : null;++ if (!h) return;++ if (m.t === 'res' && h.onRes) h.onRes(m);++ else if (m.t === 'abort') { this.release(m.id); if (h.onAbort) h.onAbort(m); }++ else if (m.t === 'pause' && h.onPause) h.onPause();++ else if (m.t === 'resume' && h.onResume) h.onResume();++ }++++ shutdown(code, why) {++ if (this.closed) return;++ this.closed = true;++ for (const [, h] of this.streams) { try { if (h.onAbort) h.onAbort({ code: 'tunnel_closed', message: why }); } catch { /* the visitor is gone */ } }++ this.streams.clear();++ try { this.ws.close(code, String(why || '').slice(0, 120)); } catch { /* already closing */ }++ setTimeout(() => { try { this.ws.terminate(); } catch { /* gone */ } }, 2000).unref();++ }++}++++function createTunnels({ shares, config, log = console }) {++ const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });++ const live = new Map(); // share id → Tunnel++ const stats = { connects: 0, refused: 0, replaced: 0 };++++ const pinger = setInterval(() => {++ for (const t of live.values()) {++ if (!t.alive) { t.shutdown(4008, 'no answer to ping'); continue; }++ t.alive = false;++ try { t.ws.ping(); } catch { /* closing */ }++ }++ }, config.tunnel.pingMs);++ pinger.unref();++++ function refuse(ws, code, message) {++ stats.refused++;++ try { ws.send(JSON.stringify({ t: 'error', code, message })); } catch { /* gone */ }++ // 4002: the key is wrong, 4003: the share is closed (the client stops for both); 4000: anything else.++ try { ws.close(code === 'key_invalid' ? 4002 : code === 'share_revoked' ? 4003 : 4000, message.slice(0, 120)); } catch { /* gone */ }++ }++++ function accept(ws) {++ let tunnel = null;++ const hello = setTimeout(() => refuse(ws, 'hello_timeout', 'Say hello with your connect key first.'), config.tunnel.helloMs);++ ws.on('pong', () => { if (tunnel) tunnel.alive = true; });++ ws.on('error', () => {});++ ws.on('close', () => {++ clearTimeout(hello);++ if (tunnel) {++ tunnel.shutdown(1000, 'closed');++ if (live.get(tunnel.share.id) === tunnel) live.delete(tunnel.share.id);++ }++ });++ ws.on('message', async (message, isBinary) => {++ if (tunnel) { tunnel.alive = true; return tunnel.onMessage(message, isBinary); }++ if (isBinary) return refuse(ws, 'hello_expected', 'The first message is the hello.');++ let m;++ try { m = JSON.parse(String(message)); } catch { return refuse(ws, 'hello_expected', 'The first message is the hello.'); }++ if (!m || m.t !== 'hello') return refuse(ws, 'hello_expected', 'The first message is the hello.');++ clearTimeout(hello);++ if (m.v !== frames.VERSION) return refuse(ws, 'version', `This server speaks ${frames.PROTOCOL}. Download the client again from ${config.baseUrl}/ov-connect.js`);++ let share;++ try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }++ if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');++ if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');++ const t = new Tunnel(ws, share, { log, maxStreams: config.shares.maxStreams });++ t.clientInfo = { client: String(m.client || '').slice(0, 60), port: Number.isInteger(m.port) ? m.port : null };++ const old = live.get(share.id);++ if (old) { stats.replaced++; old.shutdown(4001, 'replaced by a newer connection'); }++ live.set(share.id, t);++ tunnel = t;++ stats.connects++;++ shares.touchConnected(share.id).catch(() => {});++ t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });++ return undefined;++ });++ }++++ function handleUpgrade(req, socket, head) {++ wss.handleUpgrade(req, socket, head, (ws) => accept(ws));++ }++++ function close(shareId, code = 4003, why = 'closed') {++ const t = live.get(shareId);++ if (!t) return false;++ live.delete(shareId);++ t.shutdown(code, why);++ return true;++ }++++ function closeAll() {++ clearInterval(pinger);++ for (const id of [...live.keys()]) close(id, 1001, 'server restarting');++ wss.close();++ }++++ function status() {++ return { connected: live.size, streams: [...live.values()].reduce((n, t) => n + t.streams.size, 0), ...stats };++ }++++ return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, isConnected: (id) => live.has(id) };++}++++module.exports = { createTunnels, Tunnel, HIGH_WATER };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++/**++ * /api/v1: OpenVibe.Connect's JSON API, for a person (the connect session cookie, or a Network user token as a++ * Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).++ *++ * GET /shares your shares (website.share@1 shapes, plus connected)++ * POST /shares { label, port, mode?, named? } → 201 { share, key } the key is in this answer only++ * GET /shares/:id one of your shares++ * PATCH /shares/:id { mode?, named?, port?, note? }++ * DELETE /shares/:id close it (the host answers 410 from then on)++ * POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected++ * GET /shares/:id/visits who opened it in the last 7 days++ *++ * Errors are application/problem+json with a `code`.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { ShareError } = require('../shares');++++function createApi(ctx) {++ const { shares, tunnels, principal, proxy } = ctx;++ const r = asyncRouter();++ r.use(express.json({ limit: '16kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++ r.use(principal.requirePerson);++++ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const owner = (req) => ({ subject: req.principal.subject, username: req.principal.username });++ const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });++ const guard = (fn) => async (req, res) => {++ try { return await fn(req, res); } catch (err) {++ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);++ throw err;++ }++ };++++ r.get('/shares', guard(async (req, res) => res.json({ shares: (await shares.list(req.principal.subject)).map(shape) })));++++ r.post('/shares', guard(async (req, res) => {++ if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');++ const b = req.body || {};++ const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note });++ return res.status(201).json({ share: shape(share), key });++ }));++++ async function mine(req, res) {++ const share = await shares.get(req.params.id);++ if (!share || share.owner_subject !== req.principal.subject) { fail(res, req, 404, 'share.not_found', 'No such share.'); return null; }++ return share;++ }++++ r.get('/shares/:id', guard(async (req, res) => {++ const share = await mine(req, res);++ return share ? res.json({ share: shape(share) }) : undefined;++ }));++++ r.patch('/shares/:id', guard(async (req, res) => {++ const share = await mine(req, res);++ if (!share) return undefined;++ const b = req.body || {};++ const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note });++ proxy.forget(updated.host);++ return res.json({ share: shape(updated) });++ }));++++ r.delete('/shares/:id', guard(async (req, res) => {++ const share = await mine(req, res);++ if (!share) return undefined;++ const closed = await shares.revoke(owner(req), share.id);++ tunnels.close(share.id, 4003, 'share closed');++ proxy.forget(closed.host);++ return res.json({ share: shape(closed) });++ }));++++ r.post('/shares/:id/key', guard(async (req, res) => {++ const share = await mine(req, res);++ if (!share) return undefined;++ const { share: updated, key } = await shares.rotateKey(owner(req), share.id);++ tunnels.close(share.id, 4003, 'the key was replaced');++ return res.json({ share: shape(updated), key });++ }));++++ r.get('/shares/:id/visits', guard(async (req, res) => {++ const share = await mine(req, res);++ return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;++ }));++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * Connect's pages on connect.openvibe.website. They work without JavaScript: forms are plain POSTs.++ *++ * / the front door; signed in, your shares and the form to make one++ * POST /shares make a share → its page, with the key shown once++ * /shares/:id one share: connected or not, who may open it, who did, the command++ * POST /shares/:id/policy who can open it, the port++ * POST /shares/:id/key a new key (shown once); the connected computer is disconnected++ * POST /shares/:id/close close it: the address answers "closed" from then on++ * /grant?share=&next= the gate's sign-in step (server/edge/gate.js): signed in and allowed → back to the++ * share host with a one-time code++ * /robots.txt, /llms.txt++ */++const express = require('express');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { send } = require('../render/layout');++const views = require('../views');++const { ShareError } = require('../shares');++++function createPageRoutes(ctx) {++ const { config, shares, tunnels, gate, proxy } = ctx;++ const r = asyncRouter();++ r.use(express.urlencoded({ extended: false, limit: '16kb' }));++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);++ const owner = (req) => ({ subject: req.viewer.subject, username: req.viewer.username });++ const loginTo = (path) => `/auth/login?next=${encodeURIComponent(path)}`;++ const guard = (req, res) => {++ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }++ if (!sameOrigin(req, config.baseUrl)) {++ page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Changes to your shares must be made on connect.openvibe.website itself.', link: { href: '/', label: 'Your shares' } }) }, 403);++ return false;++ }++ return true;++ };++ const connectedIds = (list) => new Set(list.filter((s) => tunnels.isConnected(s.id)).map((s) => s.id));++++ async function homeStats() {++ const t = tunnels.status();++ const open = await ctx.s.db.value("SELECT count(*)::int FROM shares WHERE status = 'active'");++ return { connected: t.connected, shares: open };++ }++++ r.get('/', async (req, res) => {++ const list = signedIn(req) ? await shares.list(req.viewer.subject) : [];++ return page(req, res, {++ title: null,++ index: true,++ description: 'OpenVibe Connect: share one port on your computer at its own HTTPS address, behind OpenVibe sign-in. Only the people you pick can open it. One file, Node 22, no router settings.',++ body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl }),++ });++ });++++ r.post('/shares', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };++ try {++ const { share, key } = await shares.create(owner(req), input);++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, notice: 'Your share is made. Run the command below on your computer.' }) }, 201);++ } catch (err) {++ if (!(err instanceof ShareError)) throw err;++ const list = await shares.list(req.viewer.subject);++ const form = { values: input, error: err.message, username: req.viewer.username };++ return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl, form }) }, err.status || 422);++ }++ });++++ async function mine(req, res) {++ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return null; }++ const share = await shares.get(req.params.id);++ if (!share || share.owner_subject !== req.viewer.subject) {++ page(req, res, { title: 'Not found', body: views.notFound() }, 404);++ return null;++ }++ return share;++ }++++ async function showShare(req, res, share, extra = {}, status = 200) {++ const tunnel = tunnels.get(share.id);++ const visits = await shares.visits(share.id);++ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, ...extra }) }, status);++ }++++ r.get('/shares/:id', async (req, res) => {++ const share = await mine(req, res);++ if (!share) return undefined;++ const notice = req.query.saved ? 'Saved.' : null;++ return showShare(req, res, share, { notice });++ });++++ r.post('/shares/:id/policy', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const share = await mine(req, res);++ if (!share) return undefined;++ try {++ const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port) });++ proxy.forget(updated.host);++ return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);++ } catch (err) {++ if (!(err instanceof ShareError)) throw err;++ return showShare(req, res, share, { error: err.message }, err.status || 422);++ }++ });++++ r.post('/shares/:id/key', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const share = await mine(req, res);++ if (!share) return undefined;++ try {++ const { share: updated, key } = await shares.rotateKey(owner(req), share.id);++ tunnels.close(share.id, 4003, 'the key was replaced');++ return showShare(req, res, updated, { key, notice: 'A new key is made. The old one no longer works.' });++ } catch (err) {++ if (!(err instanceof ShareError)) throw err;++ return showShare(req, res, share, { error: err.message }, err.status || 422);++ }++ });++++ r.post('/shares/:id/close', async (req, res) => {++ if (!guard(req, res)) return undefined;++ const share = await mine(req, res);++ if (!share) return undefined;++ const closed = await shares.revoke(owner(req), share.id);++ tunnels.close(share.id, 4003, 'share closed');++ proxy.forget(closed.host);++ return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });++ });++++ // The gate's sign-in step. GET only; nothing changes here but a one-time code in memory.++ r.get('/grant', async (req, res) => {++ res.set('Cache-Control', 'no-store');++ const id = typeof req.query.share === 'string' ? req.query.share.slice(0, 64) : '';++ const next = gate.safeNext(typeof req.query.next === 'string' ? req.query.next : '/');++ const share = id ? await shares.get(id) : null;++ if (!share || share.status !== 'active') {++ return page(req, res, { title: 'No such share', body: views.message({ title: 'No such share', text: share ? `${share.host} is closed.` : 'That share does not exist.', link: { href: '/', label: 'OpenVibe Connect' } }) }, share ? 410 : 404);++ }++ if (!signedIn(req)) return res.redirect(303, loginTo(req.originalUrl));++ if (!shares.allows(share, req.viewer)) {++ proxy.denied(share, req.viewer);++ return page(req, res, { title: 'Not for you', body: views.grantDenied({ share, viewer: req.viewer }) }, 403);++ }++ const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username });++ res.set('Referrer-Policy', 'no-referrer');++ return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);++ });++++ r.get('/robots.txt', (req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(`User-agent: *\nAllow: /$\nDisallow: /shares\nDisallow: /grant\nDisallow: /auth\nDisallow: /api\n`);++ });++ r.get('/llms.txt', (req, res) => {++ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send([++ '# OpenVibe Connect',++ '',++ '> Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in.',++ '',++ '- Client: one file, Node 22+: curl -fsSLO https://connect.openvibe.website/ov-connect.js; node ov-connect.js --key ovc_… --port 8080',++ '- Who can open a share: only the owner, the owner and named OpenVibe usernames, or anyone signed in to OpenVibe. No public shares yet.',++ '- The app behind a share receives X-OpenVibe-User and X-OpenVibe-Subject; Host is localhost:<port>.',++ '- API (a person\'s Network token as a Bearer): GET/POST /api/v1/shares, GET/PATCH/DELETE /api/v1/shares/{id}, POST /api/v1/shares/{id}/key',++ '- Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0)',++ '',++ ].join('\n'));++ });++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,113 @@++'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 (the connect session).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience connect.openvibe.website. No Connect 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, caption 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}$/;++++// Connect's routes are a person acting for themself. 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.Connect's capabilities`);++ return function guard(req, res, next) {++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, 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, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });++ };++ }++++ /** A person (session cookie or Bearer); a write made with the session must come from this site. */++ function requirePerson(req, res, next) {++ if (req.principal.kind === 'user') {++ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {++ return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });++ }++ return next();++ }++ if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });++ return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ }++++ return { middleware, resolve, requireCapability: require_, requirePerson };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/**++ * Account export and deletion → Connect (ADR-033; openvibe-sdk/account-data). A person owns their shares and their own++ * visits to other people's shares (one row per share and day: a count, never a path or an address).++ *++ * Erasing deletes both: the shares go (and with them their visit rows, ON DELETE CASCADE), so their addresses answer++ * "no share here" and a connected computer can no longer serve them. The export leaves out key_hash: it is the++ * hash of a random key, nothing a person can use, and it is not theirs to carry elsewhere.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'share_visits', subject: 'subject', value: (usr) => usr, file: 'visits.json', erase: 'delete' },++ {++ table: 'shares', subject: 'owner_subject', value: (usr) => usr, file: 'shares.json', erase: 'delete',++ columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',++ 'created_at', 'updated_at', 'revoked_at', 'last_connected_at', 'last_visit_at', 'requests', 'bytes_in', 'bytes_out'],++ },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'connect',++ tables: TABLES,++ note: 'Your shares on OpenVibe Connect and your visits to other people\'s shares. Connect never stores what passed through a share.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * OpenVibe.Connect: process entry. `node server/index.js`++ * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.website and every <label>--<user>.openvibe.website.++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the tunnels close (their computers reconnect to++ * the next process), the counters are written, the JWKS refresher stops, the store closes and the Events++ * subscriptions stop. Exported so a test can inject `exit` and `signals: false`.++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Connect', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.tunnels.closeAll(),++ () => ctx.proxy.stop(),++ () => ctx.gate.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.Connect] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}, shares at *--*.${config.shares.domain} (db ${ctx.s.db.store})`);++ ctx.keys.ensure().catch(() => {});++ });++ server.keepAliveTimeout = 65_000;++ // A share's request may stream for as long as its app streams (server-sent events): no overall request timeout.++ server.requestTimeout = 0;++ server.headersTimeout = 60_000;++ server.on('upgrade', ctx.onUpgrade);++ 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();++++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the++ // delivery secret and the client secret.++ 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.Connect] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,++ * the OAuth client 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, tunnels = null }) {++ return createReadiness({++ service: 'connect',++ 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') },++ // How many computers are connected right now (never required: zero is a fine state).++ { name: 'tunnels', required: false, check: () => (tunnels ? { ok: true, detail: tunnels.status() } : { skipped: 'no tunnel server' }) },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c, i) => (typeof headers[i] === 'string' && headers[i] ? html`<td data-label="${headers[i]}">${c}</td>` : html`<td>${c}</td>`))}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * Connect is the "your machine" half of openvibe.website, so it wears that room's accent (openvibe-shared/rooms++ * `website`, #8EA0FF dark / #4B53C2 light); the navigation's name is `Connect`.++ */++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 { esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe Connect';++const MENU_NAME = 'Connect';++const TAGLINE = 'Your computer, on the web, privately.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Connect', href: '/', icon: 'fa-plug' },++ { label: 'Sites', href: 'https://openvibe.space', icon: 'fa-globe' },++];++const API_BASE = 'https://connect.openvibe.website';++++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 (trusted HTML), viewer, config, path, active, robots, canonical, jsonLd++ */++function layout(o) {++ const config = o.config || { baseUrl: API_BASE };++ const signedIn = o.viewer && o.viewer.kind === 'user';++ const pathNow = o.path || '/';++ const active = o.active || pathNow;++ const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;++ const navbar = {++ service: 'connect',++ // connect.openvibe.website reads as OpenVibe.Website by its host; the brand says Connect.++ brand: { tld: 'website', tldLabel: 'Connect' },++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),++ appBar: [++ { label: 'Shares', href: '/', icon: 'fa-plug' },++ { label: 'New', href: '/#new', icon: 'fa-plus' },++ ],++ silentLogin: `${String(config.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ history: { type: 'page', title: o.title || SITE_NAME },++ };++ const footer = { service: 'connect', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ 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} Share one port on your computer at its own address, behind OpenVibe sign-in.`,++ canonical,++ robots: o.robots || (o.index ? 'index, follow' : 'noindex, follow'),++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar,++ footer,++ head: `${appIcon.headTags({ site: 'website' })}++<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">++<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">++<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">++<link rel="stylesheet" href="${asset('css/connect.css')}">++<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>++<script src="${esc(ovServe.url('motion.js'))}" defer></script>++<meta name="ov-boost" content="connect@${esc(RELEASE)}">`,++ 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">${signedIn++ ? `Signed in as <strong>${esc(o.viewer.displayName || o.viewer.username || 'you')}</strong>`++ : '<a href="/auth/login">Sign in with OpenVibe</a>'}</div></noscript>++<main id="main" class="cn ov-room" style="${rooms.style('website')}">${o.body}</main>++<script>window.__OV_PAGE = ${JSON.stringify({ navbar, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ } });</script>`,++ });++}++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ public: true, maxAge: 60 })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(layout(o));++}++++module.exports = { layout, send, renderPage: layout, asset, assetVersion, setRelease, SITE_NAME, MENU_NAME, TAGLINE, NETWORK_URL, NAV, API_BASE };nnew file mode 100644@@@ -0,0 +1,250 @@++'use strict';++++/**++ * Shares: one port on a person's machine, served at https://<label>--<user>.<domain> behind OpenVibe sign-in.++ *++ * create(owner, { label, port, mode, named, note }) → { share, key } the key is returned once, never stored++ * list(ownerSubject), get(id), byHost(host), byKey(key)++ * setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale++ * rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed++ * revoke(owner, id) the host answers 410 from now on++ * hostFor(label, username), parseHost(host) the naming rule (pure)++ *++ * Names: the label and the person's handle are DNS-safe (a–z, 0–9, single hyphens, no hyphen at either end), joined++ * by "--". OpenVibe.Space's site names can never contain "--" (its NAME_RE), so a share never takes a Space site's++ * name and Space never takes a share's. The whole label is at most 63 characters.++ *++ * Modes (website.policy@1's subset that v0 serves): owner (only the person who made it), named (the owner and the++ * OpenVibe usernames listed) and signin (anyone signed in to OpenVibe). A public share waits for the abuse screen++ * and the Public Suffix List entry (ADR-057, CON-9 and CON-21), so it is refused here.++ */++const crypto = require('crypto');++++const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;++const MODES = ['owner', 'named', 'signin'];++const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);++const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;++++class ShareError extends Error {++ constructor(status, code, message) { super(message); this.status = status; this.code = code; }++}++++/** A person's OpenVibe username as one DNS-safe handle ("Japanese_Old.Guy" → "japanese-old-guy"). */++function handleOf(username) {++ return String(username || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, '');++}++++function hostFor(label, username, domain) {++ const handle = handleOf(username);++ if (!handle) return null;++ const first = `${label}--${handle}`;++ if (first.length > 63) return null;++ return `${first}.${domain}`;++}++++/** The share label in a host, or null: "agents--alex.openvibe.website" → { label: 'agents', handle: 'alex' }. */++function parseHost(host, domain) {++ const h = String(host || '').toLowerCase().replace(/:\d+$/, '');++ const suffix = `.${domain}`;++ if (!h.endsWith(suffix)) return null;++ const first = h.slice(0, -suffix.length);++ if (!first || first.length > 63 || first.includes('.')) return null;++ const at = first.indexOf('--');++ if (at <= 0) return null;++ const label = first.slice(0, at);++ const handle = first.slice(at + 2);++ if (!LABEL_RE.test(label) || !LABEL_RE.test(handle)) return null;++ return { host: h, label, handle };++}++++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');++function newKey() { return `ovc_${crypto.randomBytes(32).toString('base64url')}`; }++++function normalizeNamed(list) {++ const raw = Array.isArray(list) ? list : String(list || '').split(/[\s,]+/);++ const out = [];++ for (const item of raw) {++ const u = String(item || '').trim().replace(/^@/, '');++ if (!u) continue;++ if (!USERNAME_RE.test(u)) throw new ShareError(422, 'share.invalid_username', `"${u.slice(0, 64)}" is not an OpenVibe username`);++ const lower = u.toLowerCase();++ if (!out.includes(lower)) out.push(lower);++ }++ if (out.length > 50) throw new ShareError(422, 'share.too_many_people', 'A share can name up to 50 people.');++ return out;++}++++function createShares(s, { config }) {++ const { db } = s;++ const domain = config.shares.domain;++ const scheme = config.shares.scheme;++++ const urlOf = (row) => `${scheme}://${row.host}`;++ function view(row) {++ if (!row) return null;++ return {++ id: row.id,++ owner_subject: row.owner_subject,++ owner_username: row.owner_username,++ label: row.label,++ host: row.host,++ url: urlOf(row),++ local_port: Number(row.local_port),++ mode: row.mode,++ named: Array.isArray(row.named) ? row.named : [],++ status: row.status,++ policy_version: Number(row.policy_version),++ key_prefix: row.key_prefix,++ note: row.note || null,++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ revoked_at: iso(row.revoked_at),++ last_connected_at: iso(row.last_connected_at),++ last_visit_at: iso(row.last_visit_at),++ requests: Number(row.requests || 0),++ bytes_in: Number(row.bytes_in || 0),++ bytes_out: Number(row.bytes_out || 0),++ };++ }++++ async function list(ownerSubject) {++ const rows = await db.many("SELECT * FROM shares WHERE owner_subject = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 100", [ownerSubject]);++ return rows.map(view);++ }++ async function get(id) { return view(await db.maybe('SELECT * FROM shares WHERE id = $1', [String(id || '')])); }++ async function byHost(host) {++ return view(await db.maybe("SELECT * FROM shares WHERE host = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [String(host || '').toLowerCase()]));++ }++ async function byKey(key) {++ if (typeof key !== 'string' || !/^ovc_[A-Za-z0-9_-]{43}$/.test(key)) return null;++ return view(await db.maybe('SELECT * FROM shares WHERE key_hash = $1', [sha256(key)]));++ }++++ async function owned(owner, id) {++ const share = await get(id);++ if (!share || share.owner_subject !== owner.subject) throw new ShareError(404, 'share.not_found', 'No such share.');++ return share;++ }++++ async function create(owner, input = {}) {++ if (!owner || !owner.subject || !owner.username) throw new ShareError(401, 'token.required', 'Sign in to make a share.');++ const label = String(input.label || '').trim().toLowerCase();++ if (!LABEL_RE.test(label)) throw new ShareError(422, 'share.invalid_label', 'A name is 1–40 characters: a–z, 0–9 and single hyphens, not at either end.');++ if (RESERVED_LABELS.has(label)) throw new ShareError(422, 'share.reserved_label', `"${label}" is kept for OpenVibe itself. Pick another name.`);++ const port = Number(input.port);++ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');++ const mode = input.mode == null || input.mode === '' ? 'owner' : String(input.mode);++ if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in. For now a share asks every visitor to sign in with OpenVibe.');++ if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);++ const named = normalizeNamed(input.named);++ const host = hostFor(label, owner.username, domain);++ if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');++ const note = input.note ? String(input.note).slice(0, 200) : null;++++ const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);++ if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);++ // A host someone else used stays theirs (a revoked share holds its address, so nobody else can serve on links++ // that still point there). The owner may reuse their own.++ const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = $1 ORDER BY created_at DESC LIMIT 1', [host]);++ if (taken && (taken.status === 'active' || taken.owner_subject !== owner.subject)) {++ throw new ShareError(409, 'share.taken', taken.owner_subject === owner.subject ? 'You already have a share with that name.' : 'That address is taken. Pick another name.');++ }++++ const key = newKey();++ const id = s.newId('shr');++ try {++ await db.exec(++ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,++ [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note],++ );++ } catch (err) {++ if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');++ throw err;++ }++ return { share: await get(id), key };++ }++++ async function setPolicy(owner, id, input = {}) {++ const share = await owned(owner, id);++ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');++ const mode = input.mode == null ? share.mode : String(input.mode);++ if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in.');++ if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);++ const named = input.named === undefined ? share.named : normalizeNamed(input.named);++ const port = input.port === undefined ? share.local_port : Number(input.port);++ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');++ const note = input.note === undefined ? share.note : (input.note ? String(input.note).slice(0, 200) : null);++ const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);++ await db.exec(++ `UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),++ policy_version = policy_version + $6 WHERE id = $1`,++ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0],++ );++ return get(share.id);++ }++++ async function rotateKey(owner, id) {++ const share = await owned(owner, id);++ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');++ const key = newKey();++ await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);++ return { share: await get(share.id), key };++ }++++ async function revoke(owner, id) {++ const share = await owned(owner, id);++ if (share.status === 'revoked') return share;++ await db.exec("UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), policy_version = policy_version + 1 WHERE id = $1", [share.id]);++ return get(share.id);++ }++++ async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }++++ /** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */++ async function flushCounters(counters, day) {++ for (const [id, c] of Object.entries(counters)) {++ await db.exec(++ `UPDATE shares SET requests = requests + $2, bytes_in = bytes_in + $3, bytes_out = bytes_out + $4,++ last_visit_at = CASE WHEN $2 > 0 THEN now() ELSE last_visit_at END WHERE id = $1`,++ [id, c.requests, c.bytesIn, c.bytesOut],++ );++ for (const [subject, v] of Object.entries(c.visits || {})) {++ await db.exec(++ `INSERT INTO share_visits (share_id, day, subject, username, requests, denied, last_at) VALUES ($1, $2, $3, $4, $5, $6, now())++ ON CONFLICT (share_id, day, subject) DO UPDATE SET requests = share_visits.requests + EXCLUDED.requests,++ denied = share_visits.denied + EXCLUDED.denied, username = COALESCE(EXCLUDED.username, share_visits.username), last_at = now()`,++ [id, day, subject, v.username || null, v.requests || 0, v.denied || 0],++ );++ }++ }++ }++++ async function visits(id, { days = 7 } = {}) {++ const rows = await db.many(++ `SELECT subject, max(username) AS username, sum(requests)::int AS requests, sum(denied)::int AS denied, max(last_at) AS last_at++ FROM share_visits WHERE share_id = $1 AND day >= (current_date - $2::int) GROUP BY subject ORDER BY max(last_at) DESC LIMIT 50`,++ [id, days],++ );++ return rows.map((r) => ({ subject: r.subject, username: r.username, requests: r.requests, denied: r.denied, last_at: iso(r.last_at) }));++ }++++ /** Is this person let in by the share's policy? */++ function allows(share, viewer) {++ if (!share || share.status !== 'active' || !viewer || !viewer.subject) return false;++ if (viewer.subject === share.owner_subject) return true;++ if (share.mode === 'signin') return true;++ if (share.mode === 'named') return Boolean(viewer.username) && share.named.includes(String(viewer.username).toLowerCase());++ return false;++ }++++ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, touchConnected, flushCounters, visits, allows, view, domain };++}++++function iso(v) {++ if (!v) return null;++ const d = v instanceof Date ? v : new Date(v);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++module.exports = { createShares, ShareError, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };nnew file mode 100644@@@ -0,0 +1,243 @@++'use strict';++++/**++ * Connect's pages, server-rendered and complete without JavaScript (forms are plain POSTs). The front door is++ * sc.room (hero → your shares → scenes → developers → questions → the rest of OpenVibe); a share has one page with++ * its address, whether it is connected, who may open it, who did, and the command. A key is shown once, on the page++ * that made it.++ */++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++const { esc } = require('../render/html');++++const CLIENT_URL = (base) => `${base}/ov-connect.js`;++const MODE_TEXT = {++ owner: 'Only you',++ named: 'You and the people you name',++ signin: 'Anyone signed in to OpenVibe',++};++++function fmtBytes(n) {++ const v = Number(n) || 0;++ if (v < 1024) return `${v} B`;++ if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`;++ if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`;++ return `${(v / 1024 ** 3).toFixed(2)} GB`;++}++function ago(iso, now = Date.now()) {++ if (!iso) return 'never';++ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));++ if (s < 60) return 'just now';++ if (s < 3600) return `${Math.round(s / 60)} min ago`;++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;++ return `${Math.round(s / 86400)} days ago`;++}++++/** The hero's picture: a port on a laptop, a line through OpenVibe, an address on a phone with a lock. */++function motif() {++ return `<div class="cn-motif" aria-hidden="true">++<div class="cn-m-box cn-m-pc"><span class="cn-m-k">your computer</span><b>localhost:8080</b><span class="cn-m-dot"></span></div>++<div class="cn-m-line"><i></i><span>one outgoing connection</span></div>++<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.openvibe.website</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div>++</div>`;++}++++function terminal(lines) {++ return `<div class="cn-term" role="img" aria-label="A terminal running ov-connect"><div class="cn-term-bar"><i></i><i></i><i></i></div><pre>${lines.map((l) => (l.startsWith('$ ') ? `<span class="cn-term-cmd">${esc(l)}</span>` : `<span class="cn-term-out">${esc(l)}</span>`)).join('\n')}</pre></div>`;++}++++function phone() {++ return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.openvibe.website</span></div>++<div class="cn-phone-body"><b>Your agent portal</b><span>Runs on your desk. Open here, over 5G.</span><span class="cn-phone-pill">Signed in as you</span></div></div>`;++}++++function picker(mode = 'named') {++ return `<div class="cn-pick" role="img" aria-label="Choosing who can open a share">${Object.entries(MODE_TEXT).map(([k, v]) => `<span class="cn-pick-row${k === mode ? ' is-on' : ''}"><i></i>${esc(v)}</span>`).join('')}++<span class="cn-pick-names"><b>@sam</b><b>@riley</b></span></div>`;++}++++function shareRow(s, { connected }) {++ const state = s.status !== 'active' ? ['closed', 'is-closed'] : connected ? ['connected', 'is-on'] : ['not connected', 'is-off'];++ return `<li class="cn-share"><a href="/shares/${esc(s.id)}">++<span class="cn-share-dot ${state[1]}" aria-hidden="true"></span>++<span class="cn-share-main"><b>${esc(s.host)}</b><span>${esc(state[0])} · port ${esc(s.local_port)} · ${esc(MODE_TEXT[s.mode] || s.mode)}</span></span>++${kit.icon('chevron')}</a></li>`;++}++++function createForm({ values = {}, error = null, username = '' } = {}) {++ const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';++ return `<form class="cn-form" method="post" action="/shares" id="new">++<h2>Make a share</h2>++${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}++<div class="cn-fields">++<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>++<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>++</div>++<fieldset class="cn-modes"><legend>Who can open it</legend>++${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${(values.mode || 'owner') === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}++</fieldset>++<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(values.named || '')}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>++<button class="cn-btn" type="submit">${kit.icon('plus')} Make the share</button>++<p class="cn-fine">Every visitor signs in with OpenVibe first. Public shares, open to anyone, come once the abuse check is in.</p>++</form>`;++}++++function yourShares({ viewer, shares, connectedIds, form }) {++ if (!viewer || viewer.kind !== 'user') return '';++ const open = shares.filter((s) => s.status === 'active');++ const closed = shares.filter((s) => s.status !== 'active');++ const list = open.length++ ? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`++ : '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';++ const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';++ return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm(form || { username: viewer.username })}</section>`;++}++++function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null }) {++ const signedIn = viewer && viewer.kind === 'user';++ const body = sc.room({++ room: 'website',++ hero: {++ title: 'Your computer, on the web, privately',++ lede: 'Share one port on your computer at its own address, like agents--you.openvibe.website. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.',++ start: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to make a share' },++ motif: motif(),++ },++ proof: [++ { value: stats.connected, label: stats.connected === 1 ? 'share connected now' : 'shares connected now', live: true },++ { value: stats.shares, label: 'shares open' },++ ],++ scenes: [++ {++ title: 'One command on your computer',++ text: 'Download ov-connect.js once and run it with your share\'s key and port. It opens one outgoing connection to OpenVibe, so it works behind any router, at home or on a phone hotspot, and it reconnects by itself after sleep.',++ html: terminal([++ `$ curl -fsSLO ${CLIENT_URL(baseUrl)}`,++ '$ node ov-connect.js --key ovc_… --port 8080',++ 'Connected. https://agents--you.openvibe.website → localhost:8080',++ 'GET / → 200 (14 ms)',++ ]),++ action: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to start' },++ },++ {++ title: 'Open it anywhere, signed in',++ text: 'Your agent portal, a dev server, a home dashboard: open its address on your phone over 5G. It is real HTTPS, so the microphone, the camera and WebSockets work. Live streams (server-sent events) stream.',++ html: phone(),++ },++ {++ title: 'You pick who gets in',++ text: 'Only you, you and the people you name, or anyone signed in to OpenVibe. Change it any time and everyone already in is asked again. Your app is told who each visitor is, so it can greet them by name.',++ html: picker(),++ },++ ],++ dev: {++ title: 'For developers',++ lede: 'The share page is also an API, with your OpenVibe token. Your app receives X-OpenVibe-User and X-OpenVibe-Subject on every request, and never the gate\'s own cookie.',++ samples: [++ { label: 'Make a share', lang: 'bash', code: `curl -X POST ${baseUrl}/api/v1/shares \\\n -H "Authorization: Bearer $OV_TOKEN" -H "Content-Type: application/json" \\\n -d '{"label":"agents","port":8790,"mode":"owner"}'` },++ { label: 'Your shares', lang: 'bash', code: `curl ${baseUrl}/api/v1/shares -H "Authorization: Bearer $OV_TOKEN"` },++ { label: 'Run the client', lang: 'bash', code: 'OV_CONNECT_KEY=ovc_… node ov-connect.js 8790' },++ ],++ links: [{ label: 'The client\'s source', href: CLIENT_URL(baseUrl) }, { label: 'llms.txt', href: '/llms.txt' }],++ },++ faq: {++ items: [++ ['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],++ ['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, you and named people, or anyone signed in. Changing who can open it signs everyone out of that share at once.'],++ ['Can I make it public?', 'Not yet. A public address can be abused for phishing, so public shares come with the abuse check and openvibe.website\'s entry in the Public Suffix List. Until then every visitor signs in.'],++ ['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],++ ['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],++ ['How is this different from ngrok or Tailscale?', 'It is part of your OpenVibe account: the people you let in are OpenVibe people, no separate login, no install beyond one file. TCP and UDP shares (game servers), custom domains, a private mesh between your machines and durable webhooks are the next steps on the plan.'],++ ],++ },++ network: {++ service: 'website',++ title: 'Part of OpenVibe',++ lede: 'Connect is the "your machine" half of openvibe.website. The other half builds and hosts sites.',++ items: [++ { to: 'space', what: 'Build and host a site at <name>.openvibe.website', href: 'https://openvibe.space' },++ { to: 'network', what: 'The sign-in your visitors use', href: 'https://openvibe.network' },++ { to: 'codes', what: 'Your agents and machines', href: 'https://openvibe.codes' },++ ],++ },++ });++ const yours = yourShares({ viewer, shares, connectedIds, form });++ const at = body.indexOf('<section class="sc-room-scenes"');++ return `<div class="cn-wrap">${at < 0 ? body + yours : body.slice(0, at) + yours + body.slice(at)}</div>`;++}++++function keyBlock(share, key, baseUrl) {++ const cmd = `node ov-connect.js --key ${key} --port ${share.local_port}`;++ return `<section class="cn-key" aria-labelledby="cn-key-h">++<h2 id="cn-key-h">Your connect key</h2>++<p>Shown this once. It lets a computer serve this share, so keep it like a password. Lost it? Make a new one below; the old one stops working.</p>++<div class="cn-copy"><code id="cn-key-v">${esc(key)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-key-v" hidden>${kit.icon('copy')} Copy</button></div>++<h3>On your computer</h3>++<ol class="cn-steps">++<li><span>Download the client once (Node 22 or newer):</span><div class="cn-copy"><code id="cn-dl">curl -fsSLO ${esc(CLIENT_URL(baseUrl))}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-dl" hidden>${kit.icon('copy')} Copy</button></div></li>++<li><span>Run it next to what you are sharing:</span><div class="cn-copy"><code id="cn-run">${esc(cmd)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-run" hidden>${kit.icon('copy')} Copy</button></div></li>++<li><span>Open <a href="${esc(share.url)}">${esc(share.host)}</a> anywhere and sign in.</span></li>++</ol>++</section>`;++}++++function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, now = Date.now() }) {++ const closed = share.status !== 'active';++ const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';++ const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`++ : connected ? `Connected ${ago(tunnel && tunnel.connectedAt ? new Date(tunnel.connectedAt).toISOString() : share.last_connected_at, now)}${tunnel && tunnel.clientInfo && tunnel.clientInfo.port ? `, serving localhost:${tunnel.clientInfo.port}` : ''}.`++ : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. Run ov-connect on your computer to bring it back.` : 'Never connected yet. Run the command below on your computer.';++ const visitRows = visits.length++ ? `<ul class="cn-visits">${visits.map((v) => `<li><b>${v.username ? `@${esc(v.username)}` : 'someone'}</b><span>${esc(v.requests)} ${v.requests === 1 ? 'request' : 'requests'}${v.denied ? `, ${esc(v.denied)} turned away` : ''} · ${esc(ago(v.last_at, now))}</span></li>`).join('')}</ul>`++ : '<p class="cn-empty">Nobody has opened it in the last 7 days.</p>';++ const policy = closed ? '' : `<form class="cn-form cn-policy" method="post" action="/shares/${esc(share.id)}/policy">++<h2>Who can open it</h2>++<fieldset class="cn-modes"><legend class="cn-sr">Who can open it</legend>++${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${share.mode === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}++</fieldset>++<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(share.named.join(', '))}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>++<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(share.local_port)}"></label>++<button class="cn-btn" type="submit">${kit.icon('check')} Save</button>++<p class="cn-fine">Changing who can open it signs everyone out of this share; the people still allowed are let back in when they next open it.</p>++</form>`;++ const danger = closed ? '' : `<section class="cn-danger" aria-labelledby="cn-danger-h"><h2 id="cn-danger-h">Key and closing</h2>++<form method="post" action="/shares/${esc(share.id)}/key"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('key')} Make a new key</button><span>The old key stops working and a connected computer is disconnected.</span></form>++<form method="post" action="/shares/${esc(share.id)}/close"><button class="cn-btn cn-btn-bad" type="submit">${kit.icon('close')} Close this share</button><span>The address answers "closed" from now on and stays yours.</span></form>++</section>`;++ return `<div class="cn-wrap cn-page">++<p class="cn-back"><a href="/">${kit.icon('back')} Your shares</a></p>++${notice ? `<p class="cn-notice" role="status">${esc(notice)}</p>` : ''}++${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}++<header class="cn-head"><span class="cn-state ${closed ? 'is-closed' : connected ? 'is-on' : 'is-off'}"><i aria-hidden="true"></i>${esc(state)}</span>++<h1>${closed ? esc(share.host) : `<a href="${esc(share.url)}">${esc(share.host)}</a>`}</h1>++<p>${esc(stateText)}</p></header>++${key ? keyBlock(share, key, baseUrl) : ''}++<dl class="cn-facts">++<div><dt>Who can open it</dt><dd>${esc(MODE_TEXT[share.mode] || share.mode)}${share.mode === 'named' && share.named.length ? `: ${share.named.map((n) => `@${esc(n)}`).join(', ')}` : ''}</dd></div>++<div><dt>Port</dt><dd>localhost:${esc(share.local_port)}</dd></div>++<div><dt>Requests</dt><dd>${esc(Number(share.requests).toLocaleString('en-US'))}</dd></div>++<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>++<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>++<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>++</dl>++${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}++<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>++${policy}++${danger}++</div>`;++}++++function grantDenied({ share, viewer }) {++ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><span class="cn-state is-closed"><i aria-hidden="true"></i>Not for you</span>++<h1>You can't open ${esc(share.host)}</h1>++<p>${esc(share.owner_username)} has not let ${viewer && viewer.username ? `@${esc(viewer.username)}` : 'this account'} in. Ask them to add your OpenVibe username to the share.</p></header>++<p><a class="cn-btn cn-btn-quiet" href="/auth/logout?next=${encodeURIComponent(`/grant?share=${share.id}`)}">Use another account</a></p></div>`;++}++++function message({ title, text, link = null }) {++ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><h1>${esc(title)}</h1><p>${esc(text)}</p></header>${link ? `<p><a class="cn-btn cn-btn-quiet" href="${esc(link.href)}">${esc(link.label)}</a></p>` : ''}</div>`;++}++++function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }++++module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * ADR-033: Connect's part of an account export (a person's shares, without the key hash, and their visits) and of a++ * deletion, applied through /internal/events with a stand-in Network. A deletion erases the person's shares and++ * visits and leaves other people's alone. 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 } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++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_connect', 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: 'connect', clientSecret: 'connect-secret' });++ const t = await boot({ env: { CONNECT_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const db = t.ctx.s.db;++ const shareA = `shr_${ids.ulid()}`;++ const shareB = `shr_${ids.ulid()}`;++ await db.exec(++ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, key_prefix, key_hash)++ VALUES ($1, $2, 'ann', 'agents', 'agents--ann.openvibe.website', 8790, 'ovc_aaaaaa', 'hash-a'),++ ($3, $4, 'ben', 'blog', 'blog--ben.openvibe.website', 3000, 'ovc_bbbbbb', 'hash-b')`,++ [shareA, A, shareB, B],++ );++ // A visited B's share; B visited A's.++ await db.exec(`INSERT INTO share_visits (share_id, day, subject, username, requests) VALUES ($1, current_date, $2, 'ann', 3), ($3, current_date, $4, 'ben', 2)`, [shareB, A, shareA, B]);++++ await check('an export carries only A\'s shares (no key hash) and A\'s visits', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_connect');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['shares.json', 'visits.json']);++ const shares = part.body.files.find((f) => f.name === 'shares.json').content;++ assert.deepStrictEqual(shares.map((w) => w.id), [shareA]);++ assert.ok(!JSON.stringify(part.body).includes('hash-a'), 'the key hash stays here');++ const visits = part.body.files.find((f) => f.name === 'visits.json').content;++ assert.deepStrictEqual(visits.map((v) => v.share_id), [shareB]);++ });++++ await check('a deletion erases A\'s shares and visits and confirms with counts; B\'s share stays', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares WHERE owner_subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM share_visits WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares 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(confirmation.body.erased.shares >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'unchanged');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares WHERE owner_subject = $1', [A])), 0);++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,189 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.CONNECT_RATE_LIMIT_PER_MIN = process.env.CONNECT_RATE_LIMIT_PER_MIN || '100000';++process.env.CONNECT_API_RATE_LIMIT_PER_MIN = process.env.CONNECT_API_RATE_LIMIT_PER_MIN || '100000';++/**++ * Boots OpenVibe.Connect on a temp database with a mock Network (test/helpers/mocks.js), captures log lines, and++ * returns small HTTP clients. Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin }) connect.openvibe.website++ * t.share(host, path, { cookie, headers, method, body }) a share host (Host header set)++ * t.signIn(user) → cookie header value (connect_at = a Network user token, as after /auth/callback)++ * t.client({ key, port }) → a started ov-connect client against this server (public/ov-connect.js)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork } = require('./mocks');++++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 env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://connect.openvibe.website', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'connect', OV_OAUTH_CLIENT_SECRET: 'connect-secret', COOKIE_SECURE: 'false',++ CONNECT_DRILL: '1',++ CONNECT_EVENTS_URL: network.url,++ CONNECT_SHARE_SCHEME: 'https',++ CONNECT_SESSION_SECRET: 'test-session-secret-0123456789abcdef0123',++ CONNECT_PING_MS: '60000',++ ...(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.on('upgrade', built.ctx.onUpgrade); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const port = server.address().port;++ const base = `http://127.0.0.1:${port}`;++ const sockets = new Set();++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++++ const signIn = (user) => `connect_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ /** A request to a share host (fetch cannot set Host, so this is http.request). */++ function share(host, p = '/', o = {}) {++ return new Promise((resolve, reject) => {++ const headers = { host, accept: 'text/html', ...(o.headers || {}) };++ if (o.cookie) headers.cookie = o.cookie;++ const req = http.request({ host: '127.0.0.1', port, path: p, method: o.method || 'GET', headers }, (res) => {++ const chunks = [];++ if (o.onResponse) o.onResponse(res);++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const buf = Buffer.concat(chunks);++ resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), buffer: buf, json() { return JSON.parse(buf.toString('utf8')); } });++ });++ res.on('error', reject);++ });++ req.on('error', reject);++ if (o.body) req.write(o.body);++ req.end();++ });++ }++++ const clients = [];++ function client({ key, port: localPort, quiet = true, server: serverUrl }) {++ const { createClient } = require('../../public/ov-connect');++ const lines = [];++ const c = createClient({ key, port: localPort, server: serverUrl || `ws://127.0.0.1:${port}/tunnel`, quiet, log: (l) => lines.push(l), localHost: '127.0.0.1' });++ c.lines = lines;++ c.ready = () => new Promise((resolve) => c.onReady(resolve));++ clients.push(c);++ c.start();++ return c;++ }++++ /** Make a share as `user` through the API → { share, key }. */++ async function makeShare(user, body) {++ const r = await get('/api/v1/shares', { as: user, json: body });++ if (r.status !== 201) throw new Error(`share not made: ${r.status} ${r.text}`);++ return r.json();++ }++++ /** Walk the gate as `user` for a share: grant → callback → the share's own cookie header value. */++ async function enter(user, sh, next = '/') {++ const g = await get(`/grant?share=${encodeURIComponent(sh.id)}&next=${encodeURIComponent(next)}`, { as: user });++ if (g.status !== 303) return { status: g.status, grant: g };++ const loc = new URL(g.headers.get('location'));++ const cb = await share(sh.host, `${loc.pathname}${loc.search}`);++ const setCookie = [].concat(cb.headers['set-cookie'] || [])[0] || '';++ return { status: cb.status, location: cb.headers.location, cookie: setCookie.split(';')[0], setCookie };++ }++++ 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, port, network, config, ctx: built.ctx, get, share, signIn, dbDump, client, makeShare, enter,++ logs: () => captured.join('\n'),++ async close() {++ for (const c of clients) c.stop();++ built.ctx.tunnels.closeAll();++ built.ctx.gate.stop();++ await built.ctx.proxy.stop();++ for (const s of sockets) s.destroy();++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await built.ctx.telemetry.stop().catch(() => {});++ await testdb.close();++ await network.close();++ },++ };++ return t;++}++++/** A tiny local app for a share to point at: echoes what it got, streams on /sse, upgrades on /ws (raw echo). */++function startLocalApp() {++ return new Promise((resolve) => {++ const seen = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') });++ if (req.url === '/sse') {++ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });++ let n = 0;++ const iv = setInterval(() => { n++; res.write(`data: tick ${n}\n\n`); if (n === 3) { clearInterval(iv); res.end(); } }, 20);++ return;++ }++ if (req.url === '/big') {++ res.writeHead(200, { 'content-type': 'application/octet-stream' });++ res.end(Buffer.alloc(3 * 1024 * 1024, 7));++ return;++ }++ if (req.url === '/cookie') {++ res.writeHead(200, { 'set-cookie': ['app=1; Domain=.openvibe.website; Path=/', '__Host-ovc=forged; Path=/; Secure'], 'cache-control': 'public, max-age=600' });++ res.end('ok');++ return;++ }++ res.writeHead(200, { 'content-type': 'application/json', 'x-app': 'yes' });++ res.end(JSON.stringify({ method: req.method, url: req.url, user: req.headers['x-openvibe-user'] || null, host: req.headers.host, cookie: req.headers.cookie || null, body: Buffer.concat(chunks).toString('utf8') }));++ });++ });++ server.on('upgrade', (req, socket) => {++ seen.push({ upgrade: true, url: req.url, headers: req.headers });++ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: echo\r\nConnection: Upgrade\r\n\r\n');++ socket.on('data', (d) => socket.write(Buffer.concat([Buffer.from('echo:'), d])));++ });++ const sockets = new Set();++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, seen, close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }) }));++ });++}++++module.exports = { boot, startLocalApp };nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++++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); }++function assert(cond, message) { if (!cond) throw new Error(message || 'assertion failed'); }++function equal(a, b, message) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${message || 'not equal'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }++module.exports = { check, done, assert, equal };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * CONNECT_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.CONNECT_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'connect', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,137 @@++'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 `connect`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Connect, 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: [], 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 !== 'connect' || body.client_secret !== 'connect-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:connect', 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 });++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setDown(v) { st.down = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * The pages without JavaScript: the front door for a guest and for a person, making a share with the form (the key++ * shown on that page only), a refused name keeping what was typed, the share page, who can open it, a new key,++ * closing, the grant's "not for you" page, cross-site writes refused, and another person's share is a 404.++ */++const assert = require('assert');++const { boot } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ let id;++++ await check('a guest sees what Connect is and a sign-in to start; no form', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /Your computer, on the web, privately/);++ assert.match(r.text, /Sign in to make a share/);++ assert.ok(!r.text.includes('action="/shares"'));++ assert.match(r.text, /<meta name="robots" content="index, follow"/);++ });++++ await check('signed in: your shares and the form, with your own address in it', async () => {++ const r = await t.get('/', { as: kim });++ assert.match(r.text, /No shares yet/);++ assert.match(r.text, /action="\/shares"/);++ assert.match(r.text, /--kim\.openvibe\.website/);++ });++++ await check('the form makes a share and shows its key once, with the commands', async () => {++ const r = await t.get('/shares', { as: kim, form: { label: 'portal', port: '8790', mode: 'owner', named: '' } });++ assert.strictEqual(r.status, 201, r.text.slice(0, 300));++ const key = r.text.match(/ovc_[A-Za-z0-9_-]{43}/)[0];++ assert.match(r.text, /Your connect key/);++ assert.ok(r.text.includes(`node ov-connect.js --key ${key} --port 8790`));++ assert.match(r.text, /curl -fsSLO https:\/\/connect\.openvibe\.website\/ov-connect\.js/);++ assert.match(r.headers.get('cache-control'), /no-store|private/);++ id = (await t.get('/api/v1/shares', { as: kim })).json().shares[0].id;++ const again = await t.get(`/shares/${id}`, { as: kim });++ assert.strictEqual(again.status, 200);++ assert.ok(!again.text.includes(key), 'never shown again');++ assert.match(again.text, /The key was shown once/);++ });++++ await check('a refused name says why and keeps what was typed', async () => {++ const r = await t.get('/shares', { as: kim, form: { label: 'portal', port: '8790', mode: 'named', named: 'sam' } });++ assert.strictEqual(r.status, 409);++ assert.match(r.text, /You already have a share with that name/);++ assert.match(r.text, /value="sam"/);++ });++++ await check('who can open it: saved, then shown', async () => {++ const r = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', port: '8791' } });++ assert.strictEqual(r.status, 303);++ const page = await t.get(r.headers.get('location'), { as: kim });++ assert.match(page.text, /Saved\./);++ assert.match(page.text, /@lee, @sam/);++ assert.match(page.text, /localhost:8791/);++ });++++ await check('another site cannot change a share (the form must come from Connect)', async () => {++ const r = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {}, origin: 'https://evil.example' });++ assert.strictEqual(r.status, 403);++ assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].status, 'active');++ const api = await t.get(`/api/v1/shares/${id}`, { as: kim, method: 'DELETE', origin: 'https://evil.example' });++ assert.strictEqual(api.status, 403);++ assert.strictEqual(api.json().code, 'connect.cross_site');++ });++++ await check('another person: the share page is a 404, and the grant says "not for you"', async () => {++ assert.strictEqual((await t.get(`/shares/${id}`, { as: lee })).status, 404);++ await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'owner', named: '' } });++ const g = await t.get(`/grant?share=${id}`, { as: lee });++ assert.strictEqual(g.status, 403);++ assert.match(g.text, /You can.t open portal--kim\.openvibe\.website/);++ assert.match(g.text, /Use another account/);++ });++++ await check('a new key is shown once on the page; closing says so and keeps the address', async () => {++ const k = await t.get(`/shares/${id}/key`, { as: kim, method: 'POST', form: {} });++ assert.strictEqual(k.status, 200);++ assert.match(k.text, /A new key is made/);++ assert.match(k.text, /ovc_[A-Za-z0-9_-]{43}/);++ const c = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {} });++ assert.match(c.text, /Closed\. The address answers/);++ assert.ok(!c.text.includes('action="/shares/' + id + '/policy"'), 'a closed share has no settings');++ const home = await t.get('/', { as: kim });++ assert.match(home.text, /Closed shares \(1\)/);++ });++++ await check('signed out, the share pages ask for sign-in first', async () => {++ const r = await t.get(`/shares/${id}`);++ assert.strictEqual(r.status, 303);++ assert.match(r.headers.get('location'), /^\/auth\/login\?next=/);++ });++++ await check('robots, llms.txt and the client download', async () => {++ assert.match((await t.get('/robots.txt')).text, /Disallow: \/grant/);++ assert.match((await t.get('/llms.txt')).text, /OpenVibe Connect/);++ const js = await t.get('/ov-connect.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ assert.match(js.text, /ovconnect-ws\/1/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++/**++ * The pure rules: share names and hosts, the wire frames, the gate's cookie and its "next", and the client being one++ * file with nothing but Node's own modules.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { check, done } = require('./helpers/check');++const { hostFor, parseHost, handleOf, normalizeNamed } = require('../server/shares');++const frames = require('../server/edge/frames');++const { createGate } = require('../server/edge/gate');++++(async () => {++ await check('a handle is the username made DNS-safe; a host joins label and handle with "--"', async () => {++ assert.strictEqual(handleOf('JapaneseOldGuy'), 'japaneseoldguy');++ assert.strictEqual(handleOf('Alex_Dev.2'), 'alex-dev-2');++ assert.strictEqual(handleOf('__'), '');++ assert.strictEqual(hostFor('agents', 'Alex', 'openvibe.website'), 'agents--alex.openvibe.website');++ assert.strictEqual(hostFor('a'.repeat(40), 'b'.repeat(30), 'openvibe.website'), null, 'over 63 characters');++ assert.strictEqual(hostFor('x', '__', 'openvibe.website'), null);++ });++++ await check('parseHost reads only a share host: one label, "--" once, both sides DNS-safe', async () => {++ assert.deepStrictEqual(parseHost('Agents--Alex.openvibe.website:443', 'openvibe.website'), { host: 'agents--alex.openvibe.website', label: 'agents', handle: 'alex' });++ const notShares = ['openvibe.website', 'connect.openvibe.website', 'mysite.openvibe.website', 'a.b--c.openvibe.website', '--x.openvibe.website',++ 'x--.openvibe.website', 'x---y.openvibe.website', 'x--y.openvibe.website.evil.com', 'x--y.evil.com', '', null];++ notShares.forEach((h) => assert.strictEqual(parseHost(h, 'openvibe.website'), null, String(h)));++ });++++ await check('named people: lowercase, deduplicated, @ allowed, junk refused', async () => {++ assert.deepStrictEqual(normalizeNamed('@Sam, riley sam'), ['sam', 'riley']);++ assert.throws(() => normalizeNamed(['bad name!']), /not an OpenVibe username/);++ });++++ await check('frames: data and end round-trip; anything else is dropped', async () => {++ const f = frames.decode(frames.encode(frames.KIND.DATA, 70000, Buffer.from('hi')));++ assert.deepStrictEqual([f.kind, f.id, f.payload.toString()], [1, 70000, 'hi']);++ assert.strictEqual(frames.decode(frames.encode(frames.KIND.END, 3)).payload.length, 0);++ assert.strictEqual(frames.decode(Buffer.from([9, 0, 0, 0, 1])), null);++ assert.strictEqual(frames.decode(Buffer.from([1, 0])), null);++ });++++ await check('the gate\'s cookie: bound to the share, its policy version and its expiry', async () => {++ let now = 1_800_000_000_000;++ const config = { shares: { sessionSecret: 'x'.repeat(40), sessionHours: 12 }, cookies: { secure: true } };++ const gate = createGate({ config, now: () => now });++ const share = { id: 'shr_1', policy_version: 2 };++ const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2 });++ assert.deepStrictEqual(gate.verify(v, share), { subject: 'usr_1', username: 'alex' });++ assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2 }), null);++ assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3 }), null);++ assert.strictEqual(gate.verify(`${v}x`, share), null);++ now += 13 * 3600_000;++ assert.strictEqual(gate.verify(v, share), null, 'expired');++ assert.strictEqual(gate.cookieName, '__Host-ovc');++ assert.match(gate.cookieHeader('v'), /^__Host-ovc=v; Path=\/; HttpOnly; SameSite=Lax; Max-Age=43200; Secure$/);++ assert.deepStrictEqual(gate.splitCookies('a=1; __Host-ovc=abc; ovc=old; b=2'), { ours: 'abc', rest: 'a=1; b=2' });++ gate.stop();++ assert.throws(() => createGate({ config: { shares: { sessionSecret: 'short' }, cookies: {} } }), /CONNECT_SESSION_SECRET/);++ });++++ await check('next: a local path only', async () => {++ const gate = createGate({ config: { shares: { sessionSecret: 'y'.repeat(40), sessionHours: 1 }, cookies: { secure: false } } });++ const cases = [['/a?b=1', '/a?b=1'], ['//evil.com', '/'], ['/\\evil.com', '/'], ['https://evil.com', '/'], ['/x\r\nSet-Cookie: a', '/'], ['/__ovc/callback', '/'], [null, '/']];++ cases.forEach(([inp, out]) => assert.strictEqual(gate.safeNext(inp), out, String(inp)));++ gate.stop();++ });++++ await check('the client is one file that needs nothing but Node', async () => {++ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'ov-connect.js'), 'utf8');++ const mods = [...src.matchAll(/require\('([^']+)'\)/g)].map((m) => m[1]);++ assert.deepStrictEqual(mods.sort(), ['http', 'net']);++ assert.match(src, /^#!\/usr\/bin\/env node/);++ const { parseArgs } = require('../public/ov-connect');++ assert.strictEqual(parseArgs(['--key', 'ovc_x', '8080']).port, 8080);++ assert.strictEqual(parseArgs(['--key=ovc_y', '--port=9']).key, 'ovc_y');++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++#!/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 Chat/Live/Video fixtures; none needs the network.++ *++ * npm test # everything++ * npm test -- importer # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,277 @@++'use strict';++/**++ * A share end to end: made through the API, the gate (grant → one-time code → the share's own host-only cookie),++ * the client (public/ov-connect.js) carrying requests to a real local app — bodies, server-sent events, a 3 MB++ * download, a WebSocket-style upgrade — the headers the app gets and the ones it may not set, who may open it, a new++ * key, closing, and the counters.++ */++const assert = require('assert');++const net = require('net');++const { boot, startLocalApp } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++async function until(fn, ms = 3000) {++ const end = Date.now() + ms;++ for (;;) {++ const v = await fn();++ if (v) return v;++ if (Date.now() > end) throw new Error('timed out waiting');++ await sleep(20);++ }++}++++(async () => {++ const t = await boot();++ const app = await startLocalApp();++ const alex = t.network.addUser('Alex_Dev');++ const bob = t.network.addUser('bob');++ let made;++ let alexCookie;++ let c;++++ await check('a share is made through the API: its address joins the name and the handle with "--"; the key comes once', async () => {++ made = await t.makeShare(alex, { label: 'agents', port: app.port });++ assert.strictEqual(made.share.host, 'agents--alex-dev.openvibe.website');++ assert.strictEqual(made.share.url, 'https://agents--alex-dev.openvibe.website');++ assert.strictEqual(made.share.mode, 'owner');++ assert.match(made.key, /^ovc_[A-Za-z0-9_-]{43}$/);++ const list = await t.get('/api/v1/shares', { as: alex });++ assert.strictEqual(list.json().shares.length, 1);++ assert.ok(!list.text.includes(made.key), 'the key is never shown again');++ assert.ok(!(await t.dbDump()).includes(made.key), 'only the key\'s hash is stored');++ });++++ await check('names: public, reserved, malformed and taken names are refused', async () => {++ const pub = await t.get('/api/v1/shares', { as: alex, json: { label: 'open', port: 80, mode: 'public' } });++ assert.strictEqual(pub.status, 422);++ assert.strictEqual(pub.json().code, 'share.public_not_yet');++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'connect', port: 80 } })).json().code, 'share.reserved_label');++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'a--b', port: 80 } })).json().code, 'share.invalid_label');++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'agents', port: 80 } })).json().code, 'share.taken');++ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'x', port: 70000 } })).json().code, 'share.invalid_port');++ });++++ await check('the gate: a browser goes to the grant, a script gets 401 with where to sign in', async () => {++ const r = await t.share(made.share.host, '/hello?x=1');++ assert.strictEqual(r.status, 302);++ assert.strictEqual(r.headers.location, `https://connect.openvibe.website/grant?share=${made.share.id}&next=${encodeURIComponent('/hello?x=1')}`);++ assert.strictEqual(r.headers['cloudflare-cdn-cache-control'], 'no-store');++ const api = await t.share(made.share.host, '/data.json', { headers: { accept: 'application/json' } });++ assert.strictEqual(api.status, 401);++ assert.strictEqual(api.json().error, 'sign_in_required');++ });++++ await check('the grant: signed out → sign in first; the owner gets a one-time code; the callback sets a host-only cookie', async () => {++ const anon = await t.get(`/grant?share=${made.share.id}&next=/hello`);++ assert.strictEqual(anon.status, 303);++ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);++ const e = await t.enter(alex, made.share, '/hello');++ assert.strictEqual(e.status, 303);++ assert.strictEqual(e.location, '/hello');++ assert.match(e.setCookie, /^ovc=[^;]+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=\d+$/, 'host-only: no Domain attribute');++ alexCookie = e.cookie;++ });++++ await check('a code works once, and only on its own share', async () => {++ const g = await t.get(`/grant?share=${made.share.id}&next=/`, { as: alex });++ const loc = new URL(g.headers.get('location'));++ const first = await t.share(made.share.host, `${loc.pathname}${loc.search}`);++ assert.strictEqual(first.status, 303);++ const again = await t.share(made.share.host, `${loc.pathname}${loc.search}`);++ assert.strictEqual(again.status, 400, 'used once');++ const other = await t.makeShare(alex, { label: 'other', port: app.port });++ const g2 = await t.get(`/grant?share=${made.share.id}&next=/`, { as: alex });++ const loc2 = new URL(g2.headers.get('location'));++ assert.strictEqual((await t.share(other.share.host, `${loc2.pathname}${loc2.search}`)).status, 400, 'bound to its share');++ await t.get(`/api/v1/shares/${other.share.id}`, { as: alex, method: 'DELETE' });++ });++++ await check('nothing connected: the visitor is told the computer is not connected', async () => {++ const r = await t.share(made.share.host, '/', { cookie: alexCookie });++ assert.strictEqual(r.status, 502);++ assert.match(r.text, /computer is not connected/);++ });++++ await check('the client connects with its key and the share answers from the local app', async () => {++ c = t.client({ key: made.key, port: app.port });++ const ready = await c.ready();++ assert.strictEqual(ready.host, made.share.host);++ const r = await t.share(made.share.host, '/hello?x=1', { cookie: `${alexCookie}; theirs=1`, headers: { accept: 'application/json', 'x-openvibe-user': 'mallory', 'x-forwarded-host': 'evil--x.openvibe.website' } });++ assert.strictEqual(r.status, 200, r.text);++ const b = r.json();++ assert.strictEqual(b.url, '/hello?x=1');++ assert.strictEqual(b.user, 'Alex_Dev', 'the app is told who, and a visitor cannot say it');++ assert.strictEqual(b.host, `localhost:${app.port}`);++ assert.strictEqual(b.cookie, 'theirs=1', 'the gate\'s cookie never reaches the app');++ const seen = app.seen[app.seen.length - 1].headers;++ assert.strictEqual(seen['x-forwarded-host'], made.share.host, 'the share\'s host, never the visitor\'s header');++ assert.strictEqual(seen['x-openvibe-subject'], alex.subject);++ assert.strictEqual(r.headers['x-app'], 'yes');++ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');++ assert.strictEqual(r.headers['cloudflare-cdn-cache-control'], 'no-store');++ assert.match(r.headers['cache-control'], /private/);++ });++++ await check('a request body goes through', async () => {++ const r = await t.share(made.share.host, '/post', { method: 'POST', cookie: alexCookie, headers: { accept: 'application/json', 'content-type': 'text/plain' }, body: 'hello from the phone' });++ assert.strictEqual(r.json().body, 'hello from the phone');++ assert.strictEqual(r.json().method, 'POST');++ });++++ await check('server-sent events stream as they come', async () => {++ const times = [];++ const r = await t.share(made.share.host, '/sse', { cookie: alexCookie, headers: { accept: 'text/event-stream' }, onResponse: (res) => res.on('data', () => times.push(Date.now())) });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers['content-type'], 'text/event-stream');++ assert.match(r.text, /data: tick 1\n\ndata: tick 2\n\ndata: tick 3/);++ assert.ok(times.length >= 2, 'more than one piece arrived');++ });++++ await check('a 3 MB response arrives whole', async () => {++ const r = await t.share(made.share.host, '/big', { cookie: alexCookie, headers: { accept: '*/*' } });++ assert.strictEqual(r.buffer.length, 3 * 1024 * 1024);++ assert.ok(r.buffer.every((x) => x === 7));++ });++++ await check('a Set-Cookie loses its Domain, and the app cannot set the gate\'s own cookie', async () => {++ const r = await t.share(made.share.host, '/cookie', { cookie: alexCookie, headers: { accept: '*/*' } });++ const set = [].concat(r.headers['set-cookie'] || []);++ assert.deepStrictEqual(set, ['app=1; Path=/']);++ assert.strictEqual(r.headers['cache-control'], 'private, max-age=600', 'public turned private: nothing past the gate is cached by a CDN');++ });++++ await check('a WebSocket-style upgrade is a byte pipe to the local app, after the gate', async () => {++ const reply = await new Promise((resolve, reject) => {++ const s = net.connect(t.port, '127.0.0.1', () => {++ s.write(`GET /ws HTTP/1.1\r\nHost: ${made.share.host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nCookie: ${alexCookie}\r\n\r\n`);++ });++ let got = '';++ s.on('data', (d) => {++ got += d.toString();++ if (got.includes('\r\n\r\n') && !got.includes('echo:')) s.write('ping');++ if (got.includes('echo:ping')) { s.destroy(); resolve(got); }++ });++ s.on('error', reject);++ setTimeout(() => reject(new Error(`no echo: ${JSON.stringify(got)}`)), 3000);++ });++ assert.match(reply, /^HTTP\/1\.1 101/);++ const up = app.seen.find((x) => x.upgrade);++ assert.strictEqual(up.headers.host, `localhost:${app.port}`);++ assert.ok(!String(up.headers.cookie || '').includes('ovc='));++ const anon = await new Promise((resolve) => {++ const s = net.connect(t.port, '127.0.0.1', () => s.write(`GET /ws HTTP/1.1\r\nHost: ${made.share.host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nAccept: application/json\r\n\r\n`));++ let got = '';++ s.on('data', (d) => { got += d; });++ s.on('close', () => resolve(got));++ });++ assert.match(anon, /^HTTP\/1\.1 401/, 'no cookie, no pipe');++ });++++ await check('who can open it: bob is turned away, then named; changing it signs everyone out of the share', async () => {++ const denied = await t.enter(bob, made.share);++ assert.strictEqual(denied.status, 403);++ assert.match(denied.grant.text, /You can.t open/);++ const patched = await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'PATCH', json: { mode: 'named', named: ['Bob'] } });++ assert.strictEqual(patched.status, 200, patched.text);++ assert.deepStrictEqual(patched.json().share.named, ['bob']);++ assert.strictEqual(patched.json().share.policy_version, made.share.policy_version + 1);++ const stale = await t.share(made.share.host, '/', { cookie: alexCookie });++ assert.strictEqual(stale.status, 302, 'the old cookie no longer opens it');++ const inBob = await t.enter(bob, made.share);++ assert.strictEqual(inBob.status, 303);++ const r = await t.share(made.share.host, '/', { cookie: inBob.cookie, headers: { accept: 'application/json' } });++ assert.strictEqual(r.json().user, 'bob');++ alexCookie = (await t.enter(alex, made.share)).cookie;++ });++++ await check('a cookie that was tampered with or made for another share does not open it', async () => {++ const [data, sig] = alexCookie.slice(4).split('.');++ const claims = JSON.parse(Buffer.from(data, 'base64url').toString());++ claims.u = bob.subject;++ const forged = `ovc=${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${sig}`;++ assert.strictEqual((await t.share(made.share.host, '/', { cookie: forged })).status, 302);++ });++++ await check('nothing listening on the port: the visitor is told so', async () => {++ const dead = await t.makeShare(alex, { label: 'dead', port: 9 });++ const cDead = t.client({ key: dead.key, port: 9 });++ await cDead.ready();++ const cookie = (await t.enter(alex, dead.share)).cookie;++ const r = await t.share(dead.share.host, '/', { cookie });++ assert.strictEqual(r.status, 502);++ assert.match(r.text, /nothing answered on port 9/);++ cDead.stop();++ });++++ await check('a wrong key is refused and the client stops', async () => {++ const bad = t.client({ key: `ovc_${'A'.repeat(43)}`, port: app.port });++ await until(() => bad.lines.some((l) => /Stopped/.test(l)));++ assert.ok(bad.lines.some((l) => /not one we know/.test(l)));++ });++++ await check('a new key disconnects the computer and the old key stops working', async () => {++ const r = await t.get(`/api/v1/shares/${made.share.id}/key`, { as: alex, method: 'POST' });++ assert.strictEqual(r.status, 200);++ const fresh = r.json().key;++ assert.notStrictEqual(fresh, made.key);++ await until(() => c.lines.some((l) => /Stopped/.test(l)));++ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false);++ const old = t.client({ key: made.key, port: app.port });++ await until(() => old.lines.some((l) => /Stopped/.test(l)));++ c = t.client({ key: fresh, port: app.port });++ await c.ready();++ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), true);++ });++++ await check('a second client for the same share takes over; the first stops', async () => {++ const key = (await t.get(`/api/v1/shares/${made.share.id}/key`, { as: alex, method: 'POST' })).json().key;++ const first = t.client({ key, port: app.port });++ await first.ready();++ const second = t.client({ key, port: app.port });++ await second.ready();++ await until(() => first.lines.some((l) => /took over/.test(l)));++ const r = await t.share(made.share.host, '/', { cookie: alexCookie, headers: { accept: 'application/json' } });++ assert.strictEqual(r.status, 200);++ c = second;++ });++++ await check('the counters: requests, bytes and who opened it reach the share page', async () => {++ await t.ctx.proxy.flush();++ const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;++ assert.ok(s.requests >= 6, `requests ${s.requests}`);++ assert.ok(s.bytes_out >= 3 * 1024 * 1024);++ const v = (await t.get(`/api/v1/shares/${made.share.id}/visits`, { as: alex })).json().visits;++ const names = v.map((x) => x.username).sort();++ assert.deepStrictEqual(names, ['Alex_Dev', 'bob']);++ assert.ok(v.find((x) => x.username === 'bob').denied >= 1, 'bob was turned away once');++ assert.strictEqual(s.connected, true);++ });++++ await check('another person cannot see, change or close someone else\'s share', async () => {++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);++ assert.strictEqual((await t.get(`/shares/${made.share.id}`, { as: bob })).status, 404);++ });++++ await check('closing: the computer is disconnected and the address answers 410; nobody else can take it', async () => {++ const r = await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'DELETE' });++ assert.strictEqual(r.json().share.status, 'revoked');++ await until(() => !t.ctx.tunnels.isConnected(made.share.id));++ const gone = await t.share(made.share.host, '/', { cookie: alexCookie });++ assert.strictEqual(gone.status, 410);++ assert.match(gone.text, /closed/);++ const again = await t.get('/api/v1/shares', { as: alex, json: { label: 'agents', port: app.port } });++ assert.strictEqual(again.status, 201, 'the owner may reuse their own address');++ });++++ await check('a host that is not a share\'s, or a share that does not exist, says so', async () => {++ assert.strictEqual((await t.share('nobody--here.openvibe.website', '/')).status, 404);++ const page = await t.get('/', { headers: { host: 'connect.openvibe.website' } });++ assert.strictEqual(page.status, 200);++ });++++ await t.close();++ await app.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++/**++ * Sign-in through openvibe-sdk/sso (PKCE S256, client id connect; the SDK's cookies ov_token/ov_refresh/ov_oauth_state,++ * and connect_at read as the session), the session-token rules (a FedCM assertion or an app token is not a++ * session), the open-redirect guard, /auth/health, truthful readiness, loopback metrics, pages without JavaScript, and++ * cross-site sign-out.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot } = require('./helpers/boot');++const { check, done } = require('./helpers/check');++const { sanitizeNext } = require('../server/auth/sso');++const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');++++(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 connect', async () => {++ const r = await t.get('/auth/login?next=/shares/shr_x');++ 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'), 'connect');++ 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'), /^[A-Za-z0-9_-]{32}$/);++ assert.match(r.headers.get('set-cookie'), /ov_oauth_state=.*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=/shares/shr_x');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = cookieJar(login);++ 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'), '/shares/shr_x');++ const set = r.headers.get('set-cookie');++ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);++ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);++ assert.match(set, /ov_oauth_state=;/);++ const at = set.match(/ov_token=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `ov_token=${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 starts the sign-in once more', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = cookieJar(login);++ 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, 302, 'the SDK recovers by itself once');++ assert.match(bad.headers.get('location'), /^\/auth\/login/);++ const retry = cookieJar(bad);++ assert.match(retry, /ov_oauth_retry=1/);++ const again = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: `${flowCookie}; ${retry}` });++ assert.strictEqual(again.status, 400);++ assert.match(again.text, /Try again/);++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));++ assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 302);++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 302);++ });++++ await check('next= only accepts same-site paths', async () => {++ const r = await t.get('/auth/login?next=//evil.example/x');++ assert.ok(/ov_oauth_next=;/.test(String(r.headers.get('set-cookie') || '')), 'a cross-site next is dropped');++ const opts = { baseUrl: 'https://connect.openvibe.website', networkUrl: 'https://openvibe.network' };++ assert.strictEqual(sanitizeNext('/shares/shr_x?x=1', opts), '/shares/shr_x?x=1');++ assert.strictEqual(sanitizeNext('https://connect.openvibe.website/drops?y=1', opts), 'https://connect.openvibe.website/drops?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://connect.openvibe.website.evil.com/x']) assert.strictEqual(sanitizeNext(v, opts), '/', v);++ });++++ await check('/auth/health says whether the Network accepts this client (the sign-in canary)', async () => {++ const r = await t.get('/auth/health');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json(), { ok: true, client: 'ok' });++ });++++ await check('the old cookie is still the session, so nobody is signed out by the move', async () => {++ const me = await t.get('/auth/me', { cookie: `connect_at=${t.network.userToken(user)}` });++ assert.strictEqual(me.status, 200);++ assert.strictEqual(me.json().user.username, 'kim');++ });++++ await check('readiness is truthful: db required; JWKS, the OAuth client 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, 'connect');++ 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 ['/']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript>/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API asks for a person, and unknown routes are problem+json 404', async () => {++ const anon = await t.get('/api/v1/shares');++ assert.strictEqual(anon.status, 401);++ assert.strictEqual(anon.json().code, 'token.required');++ const gone = await t.get('/api/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: `connect_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `connect_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: `connect_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: 'connect_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'connect_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(!/connect_at=;|connect_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=/shares', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/shares');++ assert.match(String(same.headers.get('set-cookie') || ''), /connect_at=;/);++ });++++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+# OpenVibe Connect — every variable the service reads. Production: /etc/openvibe/connect.env (names only here).
+NODE_ENV=development
+PORT=5240
+HOST=127.0.0.1
+BASE_URL=http://localhost:5240
+TRUST_PROXY=2
+
+# PostgreSQL (ADR-035). Unset in development: an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+
+# OpenVibe.Network sign-in (OAuth client `connect`).
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+OV_OAUTH_CLIENT_ID=connect
+OV_OAUTH_CLIENT_SECRET=
+
+# OpenVibe.Events: account export/deletion deliveries (comma-separated secrets for rotation).
+CONNECT_EVENTS_URL=
+CONNECT_EVENTS_SECRET=
+
+# Shares. The session secret signs visitors' cookies on share hosts (required in production, 32+ characters).
+CONNECT_SHARE_DOMAIN=openvibe.website
+CONNECT_SESSION_SECRET=
+CONNECT_SESSION_HOURS=12
+CONNECT_SHARES_PER_PERSON=5
+CONNECT_MAX_STREAMS=64
+CONNECT_MAX_BODY_BYTES=104857600
+CONNECT_REQUEST_TIMEOUT_MS=120000
+CONNECT_REQUESTS_PER_MINUTE=1200
+CONNECT_BYTES_PER_DAY=10737418240
+CONNECT_PING_MS=25000
+
+# Drill mode (restore drills): no Events subscriptions.
+CONNECT_DRILL=
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test scripts
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
+!server/data/
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+# Changelog
+
+What changed in OpenVibe Connect, newest first.
+
+## 0.1.0 — 2026-11-02
+
+The first version (Website Connect v0, plan 2026-10-28 §5.9b; ADR-057 as reviewed 2026-11-02, OQ-13): your
+computer, on the web, privately.
+
+- **Shares.** A person makes a share (a name and a local port) and it answers at
+ `https://<name>--<user>.openvibe.website`. OpenVibe.Space's site names can never contain "--", so the two never
+ meet. Five open shares per person; a closed share keeps its address.
+- **The client.** `public/ov-connect.js`: one file, Node 22+, no dependencies. It opens one outgoing WebSocket to
+ `wss://connect.openvibe.website/tunnel` (through Cloudflare on 443), says hello with the share's key, and carries
+ requests to `localhost:<port>`: HTTP with streaming bodies both ways, server-sent events, and WebSocket upgrades as a
+ byte pipe. It reconnects after sleep, stops on a wrong key or a closed share, and a newer client takes over.
+- **The gate.** Every visitor signs in with OpenVibe: `/grant` checks the share's policy (only me, me and named
+ people, anyone signed in) and hands the share host a one-time code; the host sets its own `__Host-ovc` cookie.
+ Changing who may open it signs everyone out of that share.
+- **What the app gets.** Host `localhost:<port>`, the share's host in X-Forwarded-Host, the visitor in
+ X-OpenVibe-User and X-OpenVibe-Subject; never the gate's cookie. Set-Cookie loses its Domain; nothing is cached by
+ Cloudflare.
+- **Pages and API.** The front door (sc.room), your shares and the form, a share's page (connected or not, who may
+ open it, who did in 7 days, a new key, closing), and `/api/v1/shares` for a person's token.
+- **Account export and deletion** (ADR-033): shares (without the key hash) and visits.
+- **Checks:** the pure rules, the pages, sign-in, account data, and a share end to end through a real client and a
+ real local app.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+# OpenVibe Connect
+
+**Your computer, on the web, privately.** Share one port on your computer at its own HTTPS address,
+`https://<name>--<you>.openvibe.website`, and only the people you pick can open it, signed in with OpenVibe. No router
+settings, no open ports, no app to install: one file and Node.
+
+Live at **[connect.openvibe.website](https://connect.openvibe.website)**. Part of openvibe.website: Connect is the
+"your machine" half; [OpenVibe.Space](https://openvibe.space) builds and hosts sites.
+
+## Use it
+
+1. Sign in at connect.openvibe.website and make a share: a name (`agents`), the port on your computer (`8790`), and
+ who can open it.
+2. On your computer (Node 22 or newer):
+
+ ```bash
+ curl -fsSLO https://connect.openvibe.website/ov-connect.js
+ node ov-connect.js --key ovc_… --port 8790
+ ```
+
+3. Open `https://agents--you.openvibe.website` anywhere, on a phone over 5G included, and sign in.
+
+The client opens one outgoing connection, so it works behind any router or hotspot, and it reconnects by itself. It
+only ever connects to `localhost` on the port you gave it.
+
+## What your app sees
+
+- `Host: localhost:<port>`; the share's address in `X-Forwarded-Host`; `X-Forwarded-Proto: https`.
+- Who the visitor is: `X-OpenVibe-User` (their username) and `X-OpenVibe-Subject` (their OpenVibe subject id).
+ A visitor cannot send these themselves.
+- The visitor's cookies for your share's address, never the gate's own cookie.
+- WebSockets and server-sent events work. Request bodies up to 100 MB.
+
+## Who can open a share
+
+| Mode | Who |
+|---|---|
+| `owner` (default) | only you |
+| `named` | you and the OpenVibe usernames you list |
+| `signin` | anyone signed in to OpenVibe |
+
+Public shares (no sign-in) come once the abuse check and openvibe.website's entry in the Public Suffix List are in
+(ADR-057, CON-9 and CON-21). Changing who can open a share signs everyone out of it.
+
+## API
+
+With a person's OpenVibe token as a Bearer (or the session on connect.openvibe.website):
+
+| Call | What |
+|---|---|
+| `GET /api/v1/shares` | your shares, with `connected` |
+| `POST /api/v1/shares` `{ label, port, mode?, named? }` | make one → `201 { share, key }` (the key is in this answer only) |
+| `GET /api/v1/shares/:id` | one share |
+| `PATCH /api/v1/shares/:id` `{ mode?, named?, port?, note? }` | change it |
+| `DELETE /api/v1/shares/:id` | close it (410 from then on) |
+| `POST /api/v1/shares/:id/key` | a new key → `{ share, key }`; the connected computer is disconnected |
+| `GET /api/v1/shares/:id/visits` | who opened it in the last 7 days |
+
+## How it works
+
+```
+browser ──https──▶ Cloudflare ──▶ nginx ──▶ Connect (5240) ══ WebSocket (ovconnect-ws/1) ══▶ ov-connect.js ──▶ localhost:<port>
+ gate: /grant → one-time code → __Host-ovc
+```
+
+- `server/shares.js` — shares, names, keys (hashed), policies, counters.
+- `server/edge/tunnels.js` — the computers' WebSockets (`/tunnel`), one per share; a newer one takes over.
+- `server/edge/frames.js` — the wire format: JSON control messages, binary data frames per stream.
+- `server/edge/gate.js` — the one-time codes and the signed host-only cookie.
+- `server/edge/proxy.js` — a visitor's request on a share host: the gate, then the tunnel, streaming both ways.
+- `public/ov-connect.js` — the client.
+
+The v0 transport is a WebSocket on 443 through Cloudflare (ADR-057's fallback path), so nothing needs a UDP port or a
+record that shows the origin. The QUIC edge, TCP/UDP shares, custom domains, the private mesh and durable webhooks
+are the next steps (CON-3, CON-7, CON-11 to CON-15).
+
+## Run it
+
+```bash
+npm install
+npm run dev # PGlite under data/pglite, http://localhost:5240
+npm test # every file on PGlite
+npm run test:pg # the same on the PostgreSQL + PgBouncer containers
+```
+
+Environment: `.env.example`. Production: `/etc/openvibe/connect.env`, unit `openvibe-connect`, vhost
+`deploy/nginx/connect.openvibe.website.conf`.
+
+## License
+
+AGPL-3.0-only. The client (`public/ov-connect.js`) is part of this repository under the same license.
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (connect.openvibe.website and every <name>--<user>.openvibe.website, openvibe.live, openvibe.network, openvibe.today, openvibe.tools,
+openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
+github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
+
+## What OpenVibe Connect holds
+
+- A person's shares: their subject id and username, the share's name and address, the local port they typed, who may
+ open it, its status, counters (requests, bytes) and times. The connect key is stored only as a SHA-256 hash of a
+ 32-byte random value; the key itself is shown once and never stored, logged or rendered again.
+- Visits: one row per share, person and day with a request count and a "turned away" count. No IP address, no path, no
+ query string, no header, no body.
+- Nothing that passes through a share is stored. Requests and responses stream through memory.
+- Network session tokens only in httpOnly cookies; no token is stored in the database, rendered into a page or logged.
+
+## How a share is protected
+
+- **The gate.** Every visitor signs in with OpenVibe and must be allowed by the share's policy (only the owner, the
+ owner and named people, or anyone signed in). There are no public shares: those wait for the abuse check and the
+ Public Suffix List entry for openvibe.website (ADR-057, CON-9 and CON-21).
+- **Host-only cookies.** The gate's cookie on a share host is `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
+ Domain), HMAC-signed and bound to that share and its policy version; changing the policy invalidates every cookie.
+ A Set-Cookie from the shared app loses any Domain attribute, and the app can never set or read the gate's cookie.
+- **The share comes from the Host header** nginx sets, never from X-Forwarded-Host or any other visitor header; a
+ visitor's X-OpenVibe-* headers are dropped before the app sees the request.
+- **No cache past the gate.** Every share response carries `Cloudflare-CDN-Cache-Control: no-store` and a private
+ Cache-Control.
+- **The client reaches localhost only**, on the port its owner gave it; the server cannot make it connect anywhere else.
+- **Limits:** five open shares per person, 64 requests at once and 1,200 a minute per share, 100 MB per request body,
+ 10 GB a day per person.
+
+Please report anything that gets a visitor past the gate, lets one share read another's cookies, or makes a client
+reach anything but its own port.
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+{
+ "repository": "OpenVibers/OpenVibe.Connect",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "plan": "Website Connect v0 (plan 2026-10-28 \u00a75.9b, ADR-057 reviewed 2026-11-02): share one port on your computer at https://<name>--<user>.openvibe.website behind OpenVibe sign-in",
+ "runtime": "node 22, express 4, ws 8, PostgreSQL 18 (openvibe-sdk/db, async)",
+ "port": "5240",
+ "domain": "connect.openvibe.website",
+ "contracts": "openvibe-contracts v0.278.0",
+ "sdk": "openvibe-sdk v0.82.0",
+ "shared": "openvibe-shared v4.105.0",
+ "packages": [
+ "openvibe-contracts v0.278.0",
+ "openvibe-sdk v0.82.0",
+ "openvibe-shared v4.105.0"
+ ],
+ "summary": "OpenVibe Connect puts one port on a person's computer on the web at its own HTTPS address, behind OpenVibe sign-in. A one-file client (Node 22, no dependencies) opens one outgoing WebSocket through Cloudflare and carries requests to localhost: HTTP with streaming both ways, server-sent events and WebSocket upgrades. Every visitor signs in and must be allowed by the share's policy (only the owner, named people, or anyone signed in); the share host keeps its own host-only signed cookie. Nothing that passes through is stored.",
+ "works": [
+ "Shares at <name>--<user>.openvibe.website, never colliding with Space sites; five per person; a closed share keeps its address",
+ "The client: hello with a hashed-at-rest key, streams both ways with backpressure, SSE, WebSocket upgrades as a byte pipe, reconnect, takeover, stop on a bad key",
+ "The gate: /grant with OpenVibe sign-in, one-time codes, __Host-ovc bound to the share and its policy version",
+ "What the app gets: localhost Host, X-Forwarded-Host, X-OpenVibe-User/Subject; Set-Cookie Domain removed; no CDN caching",
+ "Pages (front door, your shares, a share's page), the API for a person's token, account export/deletion",
+ "Checks on PGlite and PostgreSQL, including a share end to end through the real client"
+ ],
+ "notYet": [
+ "Public shares (abuse check CON-9, Public Suffix List CON-21)",
+ "TCP/UDP shares (CON-11/12)",
+ "Custom domains (CON-7)",
+ "The QUIC edge (CON-3)",
+ "The private mesh (CON-13)",
+ "Durable webhooks (CON-15)",
+ "The OpenVibe Node plugin (CON-4)"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "seed": "none",
+ "features": [],
+ "deployment": "ovhost connect (port 5240, drill 15240), unit openvibe-connect, database ov_connect",
+ "updated": "2026-11-02"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+## ═══════════════════════════════════════════════════════════════
+## OpenVibe Connect — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/connect.openvibe.website.conf (ov-vhost-install). The Node app (port 5240,
+## systemd unit openvibe-connect) serves two kinds of host:
+##
+## connect.openvibe.website the pages, the API, sign-in, and the computers' tunnel (WebSocket /tunnel)
+## <label>--<user>.openvibe.website a share: the gate, then the tunnel to that person's computer
+##
+## Every other *.openvibe.website host is OpenVibe.Space's (deploy/nginx/openvibe.website.conf in OpenVibe.Space),
+## whose server_name leaves out any host with "--" in it: a Space site's name can never contain "--", so the two never
+## meet. connect.openvibe.website is an exact name and wins over Space's pattern anyway.
+##
+## A share streams: no proxy buffering either way (server-sent events, uploads), WebSocket upgrades pass, and a
+## connection may stay open for an hour. The app sets every cache header itself (a share is never cached by
+## Cloudflare: Cloudflare-CDN-Cache-Control: no-store). Never log request bodies, query strings or cookies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=connect_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=connect_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=connect_tunnel:10m rate=30r/m;
+
+map $http_upgrade $connect_connection {
+ default upgrade;
+ '' close;
+}
+
+# The access log for shares keeps no query string (a share's URLs are someone else's app's).
+log_format connect_share '$remote_addr - [$time_local] "$request_method $host$uri" $status $body_bytes_sent $request_time';
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name connect.openvibe.website ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://$host$request_uri; }
+}
+
+# ── connect.openvibe.website ──────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name connect.openvibe.website;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/connect.openvibe.website.access.log;
+ error_log /var/log/nginx/connect.openvibe.website.error.log;
+
+ client_max_body_size 64k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000" 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;
+
+ # ── A computer connecting a share: one long WebSocket (the server pings every 25 s) ──
+ location = /tunnel {
+ limit_req zone=connect_tunnel burst=20 nodelay;
+ limit_req_status 429;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connect_connection;
+ proxy_buffering off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location = /auth/me {
+ limit_req zone=connect_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location /auth/ {
+ limit_req zone=connect_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location /api/ {
+ limit_req zone=connect_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location ~* ^/metrics(/|$) { return 404; }
+ location ~* ^/internal(/|$) { return 404; }
+
+ location / {
+ proxy_pass http://127.0.0.1:5240;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
+
+# ── <label>--<user>.openvibe.website: a share ─────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name ~^[a-z0-9-]+--[a-z0-9-]+\.openvibe\.website$;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.website/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.website/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/connect-shares.access.log connect_share;
+ error_log /var/log/nginx/connect-shares.error.log;
+
+ # Connect counts a body against its own cap (100 MB); Cloudflare's own cap is the same.
+ client_max_body_size 100m;
+
+ add_header X-Content-Type-Options nosniff always;
+
+ location / {
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connect_connection;
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+ proxy_pass http://127.0.0.1:5240;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe Connect — your computer, on the web, privately (connect.openvibe.website and every <name>--<user>.openvibe.website)
+# Network (sign-in, JWKS, account events) should be up first, but Connect degrades honestly without it: connected
+# shares keep passing requests for people already let in, and sign-in waits 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.connect
+EnvironmentFile=/etc/openvibe/connect.env
+Environment=NODE_ENV=production
+Environment=PORT=5240
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.connect/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+# In-flight requests get the 10 s drain; connected computers reconnect to the next process by themselves.
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Connect's own database is PostgreSQL on the host's data role; the code tree stays read-only.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+-- phase: expand
+-- OpenVibe.Connect (connect.openvibe.website): Website Connect's Share, v0 (ADR-057 as reviewed 2026-11-02, OQ-13).
+-- A share is one port on a person's machine served at https://<label>--<user>.openvibe.website behind OpenVibe
+-- sign-in. Nothing here is a secret: the connect key is stored only as a SHA-256 hash of a 32-byte random value,
+-- and the visitors' rows hold a subject id and a username, never a token, a cookie, a body or a query string.
+
+CREATE TABLE shares (
+ id text PRIMARY KEY, -- shr_<ulid>
+ owner_subject text NOT NULL, -- usr_… (Network's canonical subject)
+ owner_username text NOT NULL, -- as it was when the share was made (shown, not trusted)
+ label text NOT NULL, -- the part before "--"
+ host text NOT NULL, -- <label>--<user>.openvibe.website, lowercase
+ local_port integer NOT NULL CHECK (local_port BETWEEN 1 AND 65535),
+ mode text NOT NULL DEFAULT 'owner' CHECK (mode IN ('owner', 'named', 'signin')),
+ named jsonb NOT NULL DEFAULT '[]'::jsonb, -- lowercase usernames a 'named' share lets in
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
+ policy_version integer NOT NULL DEFAULT 1,
+ key_prefix text NOT NULL, -- the first characters of the key, to tell keys apart
+ key_hash text NOT NULL, -- hex SHA-256 of the key
+ note text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz,
+ last_connected_at timestamptz,
+ last_visit_at timestamptz,
+ requests bigint NOT NULL DEFAULT 0,
+ bytes_in bigint NOT NULL DEFAULT 0,
+ bytes_out bigint NOT NULL DEFAULT 0
+);
+-- One live share per host; a revoked share keeps its row (and its host stays held, see shares.js).
+CREATE UNIQUE INDEX shares_host_active ON shares (host) WHERE status = 'active';
+CREATE INDEX shares_owner ON shares (owner_subject, created_at DESC);
+CREATE UNIQUE INDEX shares_key_hash ON shares (key_hash);
+
+-- Who came in, one row per share, person and day: the share page's "who opened it" list. No IP, no path.
+CREATE TABLE share_visits (
+ share_id text NOT NULL REFERENCES shares(id) ON DELETE CASCADE,
+ day date NOT NULL,
+ subject text NOT NULL,
+ username text,
+ requests integer NOT NULL DEFAULT 0,
+ denied integer NOT NULL DEFAULT 0,
+ last_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (share_id, day, subject)
+);
+
+-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033).
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
+
+-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
+CREATE TABLE telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1718 @@
@@ -0,0 +1,1718 @@
+{
+ "name": "openvibe-connect",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-connect",
+ "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.278.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
+ "pg": "^8.23.1",
+ "ws": "^8.18.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/boolbase": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
+ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "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/css-select": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
+ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0",
+ "css-what": "^8.0.0",
+ "domhandler": "^6.0.1",
+ "domutils": "^4.0.2",
+ "nth-check": "^3.0.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
+ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cssomnom": {
+ "version": "0.1.11",
+ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
+ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "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/dom-serializer": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
+ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0",
+ "entities": "^8.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
+ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/domhandler": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
+ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
+ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^3.0.0",
+ "domelementtype": "^3.0.0",
+ "domhandler": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "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/entities": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
+ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "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/html-escaper": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
+ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
+ "license": "MIT"
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/htmlparser2/node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "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/linkedom": {
+ "version": "0.18.15",
+ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
+ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
+ "license": "ISC",
+ "dependencies": {
+ "css-select": "^7.0.0",
+ "cssomnom": "^0.1.11",
+ "html-escaper": "^3.0.3",
+ "htmlparser2": "^10.1.0",
+ "uhyphen": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "canvas": ">= 2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "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/nth-check": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
+ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "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.278.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.278.0",
+ "integrity": "sha512-B++7iYz9b+5aT88dm+qvicwG2oD5lUXuBmiD03IpbJtXEou0OIJI/AErZoTLM6O7x5Q8o9IWoapKFjyGtF1OqQ==",
+ "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.82.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
+ "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
+ "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.105.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
+ "integrity": "sha512-Q+VaFE78fPjCzf/t15Kmnca+UZIrobFHDoigPqn4qdzDUYpvT3gmHTfmqdFniYGDYb3rrDJloLXvb7JcMhKhDw==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
+ },
+ "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.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
+ "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/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
+ "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/ws": {
+ "version": "8.22.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz",
+ "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+{
+ "name": "openvibe-connect",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe Connect: share one port on your computer at its own HTTPS address on openvibe.website, behind OpenVibe sign-in.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "CONNECT_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe",
+ "tunnel",
+ "share",
+ "localhost"
+ ],
+ "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.278.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.105.0",
+ "pg": "^8.23.1",
+ "ws": "^8.18.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+/* OpenVibe Connect: the pieces sc.room does not draw. Every colour is a theme token (kit.css, room.css); the room's
+ accent is openvibe.website's (--room-accent / --room-fill / --room-on). Phone first; nothing moves on its own. */
+
+.cn-wrap { max-width: 1120px; margin: 0 auto; padding: 0 max(16px, env(safe-area-inset-left, 0px)) 48px; }
+.cn-page { max-width: 760px; padding-top: 20px; display: grid; gap: 22px; }
+.cn-narrow { max-width: 560px; }
+.skip { position: absolute; left: 12px; top: -60px; z-index: 1000; padding: 10px 14px; border-radius: 10px; background: var(--room-fill); color: var(--room-on); font-weight: 700; text-decoration: none; }
+.skip:focus { top: 12px; }
+.cn-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.cn h2 { font-size: 1.15rem; margin: 0 0 12px; text-wrap: balance; }
+.cn code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .9em; }
+
+/* ── The hero's picture: a port, the line, an address ── */
+.cn-motif { display: grid; gap: 10px; width: min(100%, 380px); margin: 0 auto; }
+.cn-m-box { position: relative; display: grid; gap: 4px; padding: 16px 18px; border-radius: 14px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
+.cn-m-box b { font: 600 15px/1.3 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; color: var(--ovk-text); }
+.cn-m-k { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ovk-muted); }
+.cn-m-dot { position: absolute; top: 16px; right: 16px; width: 9px; height: 9px; border-radius: 50%; background: var(--ovk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovk-ok) 22%, transparent); }
+.cn-m-line { display: flex; align-items: center; gap: 10px; padding-left: 26px; color: var(--ovk-muted); font-size: 12.5px; }
+.cn-m-line i { width: 2px; height: 34px; border-radius: 2px; background: linear-gradient(var(--room-accent), color-mix(in srgb, var(--room-accent) 30%, transparent)); }
+.cn-m-phone { border-color: color-mix(in srgb, var(--room-accent) 45%, var(--ovk-line)); }
+.cn-m-lock { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--room-strong); }
+.cn-m-lock svg, .cn-phone-url svg { width: 14px; height: 14px; flex: none; }
+
+/* ── Scenes: a terminal, a phone, the picker ── */
+.cn-term { width: 100%; max-width: 520px; border-radius: 14px; overflow: hidden; border: 1px solid var(--ovk-line); background: color-mix(in srgb, var(--ovk-bg) 70%, #000); }
+.cn-term-bar { display: flex; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--ovk-line); }
+.cn-term-bar i { width: 10px; height: 10px; border-radius: 50%; background: color-mix(in srgb, var(--ovk-muted) 45%, transparent); }
+.cn-term pre { margin: 0; padding: 14px 16px; overflow-x: auto; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #dfe6f3; white-space: pre; }
+.cn-term-cmd { color: #f2f5fb; }
+.cn-term-out { color: #9fe0b5; }
+:root[data-theme-mode="light"] .cn-term { background: #141a26; }
+
+.cn-phone { width: min(100%, 260px); margin: 0 auto; border-radius: 28px; padding: 14px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); display: grid; gap: 12px; }
+.cn-phone-url { display: flex; align-items: center; gap: 6px; padding: 8px 12px; border-radius: 999px; background: var(--ovk-input); font-size: 12px; color: var(--ovk-muted); min-width: 0; }
+.cn-phone-url span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cn-phone-url svg { color: var(--ovk-ok-text); }
+.cn-phone-body { display: grid; gap: 6px; padding: 18px 14px 22px; border-radius: 16px; background: color-mix(in srgb, var(--room-accent) 10%, var(--ovk-bg)); }
+.cn-phone-body b { font-size: 16px; }
+.cn-phone-body span { font-size: 13px; color: var(--ovk-muted); }
+.cn-phone-pill { justify-self: start; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 12px !important; color: var(--room-on) !important; background: var(--room-fill); }
+
+.cn-pick { width: min(100%, 340px); margin: 0 auto; display: grid; gap: 8px; }
+.cn-pick-row { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); font-size: 14px; color: var(--ovk-muted); }
+.cn-pick-row i { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--ovk-line); flex: none; }
+.cn-pick-row.is-on { color: var(--ovk-text); border-color: color-mix(in srgb, var(--room-accent) 55%, var(--ovk-line)); }
+.cn-pick-row.is-on i { border-color: var(--room-accent); background: radial-gradient(circle, var(--room-accent) 0 4px, transparent 5px); }
+.cn-pick-names { display: flex; gap: 6px; padding-left: 40px; }
+.cn-pick-names b { padding: 3px 10px; border-radius: 999px; font-size: 12.5px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); }
+
+/* ── Your shares, and the form ── */
+.cn-yours { display: grid; gap: 14px; margin: 8px 0 36px; padding: 22px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
+.cn-yours > h2 { margin: 0; }
+.cn-shares { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.cn-share a { display: flex; align-items: center; gap: 12px; min-height: var(--ovk-tap, 44px); padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); color: var(--ovk-text); text-decoration: none; }
+.cn-share a:hover, .cn-share a:focus-visible { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--ovk-line)); outline: 0; }
+.cn-share a > svg { width: 18px; height: 18px; color: var(--ovk-muted); flex: none; }
+.cn-share-main { display: grid; gap: 2px; min-width: 0; flex: 1; }
+.cn-share-main b { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; overflow-wrap: anywhere; }
+.cn-share-main span { font-size: 13px; color: var(--ovk-muted); }
+.cn-share-dot, .cn-state i { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--ovk-muted); }
+.is-on.cn-share-dot, .cn-state.is-on i { background: var(--ovk-ok); box-shadow: 0 0 0 4px color-mix(in srgb, var(--ovk-ok) 20%, transparent); }
+.is-off.cn-share-dot, .cn-state.is-off i { background: var(--ovk-warn); }
+.is-closed.cn-share-dot, .cn-state.is-closed i { background: var(--ovk-bad); }
+.cn-closed summary { cursor: pointer; padding: 10px 0; color: var(--ovk-muted); font-size: 14px; min-height: 24px; }
+.cn-empty { margin: 0; color: var(--ovk-muted); }
+
+.cn-form { display: grid; gap: 14px; padding-top: 6px; }
+.cn-yours .cn-form { border-top: 1px solid var(--ovk-line); padding-top: 18px; scroll-margin-top: calc(var(--ovk-top, 52px) + 16px); }
+.cn-fields { display: grid; gap: 14px; grid-template-columns: minmax(0, 1fr); }
+@media (min-width: 720px) { .cn-fields { grid-template-columns: minmax(0, 1fr) 200px; } }
+.cn-field { display: grid; gap: 6px; min-width: 0; align-content: start; }
+.cn-field > span:first-child { font-size: 14px; font-weight: 600; }
+.cn-field small { font-size: 12.5px; color: var(--ovk-muted); }
+.cn-field input, .cn-addr { box-sizing: border-box; width: 100%; min-height: var(--ovk-tap, 44px); padding: 10px 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-input); color: var(--ovk-text); font: inherit; font-size: 16px; }
+.cn-addr { display: flex; align-items: center; gap: 0; padding: 0 12px; min-width: 0; cursor: text; }
+.cn-addr input { flex: 0 1 auto; field-sizing: content; width: 10ch; min-width: 6ch; max-width: 100%; border: 0; padding: 10px 0; min-height: 0; background: transparent; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 16px; }
+.cn-addr input:focus { outline: 0; }
+.cn-addr:focus-within, .cn-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
+.cn-addr-tail { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ovk-muted); font: 14px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
+.cn-modes { display: grid; gap: 8px; margin: 0; padding: 0; border: 0; min-width: 0; }
+.cn-modes legend { font-size: 14px; font-weight: 600; margin-bottom: 8px; padding: 0; }
+.cn-modes label { display: flex; align-items: center; gap: 10px; min-height: var(--ovk-tap, 44px); padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovk-line); background: var(--ovk-bg); cursor: pointer; font-size: 15px; }
+.cn-modes label:has(input:checked) { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--ovk-line)); }
+.cn-modes input { accent-color: var(--room-accent); width: 18px; height: 18px; margin: 0; }
+@media (min-width: 720px) { .cn-modes { grid-template-columns: repeat(3, minmax(0, 1fr)); } .cn-modes legend { grid-column: 1 / -1; } }
+
+.cn-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: var(--ovk-tap, 44px); padding: 0 18px; border-radius: 12px; border: 1px solid transparent; background: var(--room-fill); color: var(--room-on); font: inherit; font-weight: 700; font-size: 15px; cursor: pointer; text-decoration: none; justify-self: start; }
+.cn-btn svg { width: 18px; height: 18px; flex: none; }
+.cn-btn:hover { filter: brightness(1.06); }
+.cn-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.cn-btn-quiet { background: transparent; color: var(--ovk-text); border-color: var(--ovk-line); }
+.cn-btn-bad { background: transparent; color: var(--ovk-bad-text); border-color: color-mix(in srgb, var(--ovk-bad) 55%, var(--ovk-line)); }
+.cn-fine { margin: 0; font-size: 13px; color: var(--ovk-muted); }
+.cn-error, .cn-notice { margin: 0; padding: 12px 14px; border-radius: 12px; font-size: 14px; }
+.cn-error { color: var(--ovk-bad-text); background: color-mix(in srgb, var(--ovk-bad) 12%, transparent); border: 1px solid color-mix(in srgb, var(--ovk-bad) 40%, transparent); }
+.cn-notice { color: var(--ovk-ok-text); background: color-mix(in srgb, var(--ovk-ok) 12%, transparent); border: 1px solid color-mix(in srgb, var(--ovk-ok) 40%, transparent); }
+
+/* ── One share ── */
+.cn-back { margin: 0; }
+.cn-back a { display: inline-flex; align-items: center; gap: 6px; min-height: var(--ovk-tap, 44px); color: var(--ovk-muted); text-decoration: none; font-size: 14px; }
+.cn-back svg { width: 16px; height: 16px; }
+.cn-head { display: grid; gap: 8px; }
+.cn-head h1 { margin: 0; font: 700 clamp(1.15rem, 4.6vw, 1.7rem)/1.25 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
+.cn-head h1 a { color: var(--room-strong); text-decoration: none; }
+.cn-head h1 a:hover { text-decoration: underline; }
+.cn-head p { margin: 0; color: var(--ovk-muted); }
+.cn-state { display: inline-flex; align-items: center; gap: 8px; justify-self: start; padding: 4px 12px 4px 10px; border-radius: 999px; font-size: 13px; font-weight: 600; border: 1px solid var(--ovk-line); }
+
+.cn-key { display: grid; gap: 12px; padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid color-mix(in srgb, var(--room-accent) 50%, var(--ovk-line)); background: color-mix(in srgb, var(--room-accent) 7%, var(--ovk-panel)); }
+.cn-key h2, .cn-key h3 { margin: 0; }
+.cn-key h3 { font-size: 1rem; margin-top: 4px; }
+.cn-key p { margin: 0; color: var(--ovk-muted); }
+.cn-copy { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
+.cn-copy code { flex: 1 1 260px; min-width: 0; padding: 10px 12px; border-radius: 10px; background: var(--ovk-input); border: 1px solid var(--ovk-line); overflow-wrap: anywhere; user-select: all; }
+.cn-steps { margin: 0; padding-left: 1.3em; display: grid; gap: 12px; }
+.cn-steps li { display: grid; gap: 6px; }
+.cn-steps li > span { color: var(--ovk-text); }
+.cn-steps a { color: var(--room-strong); }
+
+.cn-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 0; }
+.cn-facts div { padding: 12px 14px; border-radius: 12px; border: 1px solid var(--ovk-line); background: var(--ovk-panel); min-width: 0; }
+.cn-facts dt { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; color: var(--ovk-muted); }
+.cn-facts dd { margin: 4px 0 0; font-weight: 600; overflow-wrap: anywhere; }
+.cn-run { display: grid; gap: 10px; }
+.cn-run h2 { margin: 0; }
+.cn-run a { color: var(--room-strong); }
+.cn-visits { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.cn-visits li { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; padding: 10px 14px; border-radius: 10px; background: var(--ovk-panel); border: 1px solid var(--ovk-line); }
+.cn-visits span { color: var(--ovk-muted); font-size: 14px; }
+.cn-policy { padding: 20px; border-radius: var(--ovk-radius, 16px); border: 1px solid var(--ovk-line); background: var(--ovk-panel); }
+.cn-policy h2 { margin: 0; }
+.cn-danger { display: grid; gap: 12px; }
+.cn-danger h2 { margin: 0; }
+.cn-danger form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
+.cn-danger form span { font-size: 13.5px; color: var(--ovk-muted); flex: 1 1 220px; }
+
+/* On a phone the room's hero picture is a faint mark behind the heading; Connect's picture is a small diagram with
+ words in it, so it sits under the hero text instead, at full strength (nothing behind the heading). */
+@media (max-width: 759px) {
+ .cn .sc-room-pic { position: static; z-index: auto; width: auto; opacity: 1; pointer-events: auto; margin-top: 4px; }
+ .cn .cn-motif { width: 100%; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,231 @@
@@ -0,0 +1,231 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * ov-connect: put one port on your computer at your OpenVibe Connect address, behind OpenVibe sign-in.
+ *
+ * node ov-connect.js --key ovc_… --port 8080
+ * OV_CONNECT_KEY=ovc_… node ov-connect.js 8080
+ *
+ * Needs Node 22 or newer and nothing else. It only ever opens connections to localhost on the port you give it: the
+ * server cannot make it reach anything else. Your key is sent once per connection, over TLS, as the first message.
+ *
+ * --key <ovc_…> your share's connect key (or OV_CONNECT_KEY)
+ * --port <n> the local port to share (or the first bare number)
+ * --server <wss://> default wss://connect.openvibe.website/tunnel
+ * --quiet no line per request
+ *
+ * Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0). Protocol: ovconnect-ws/1 (server/edge/frames.js).
+ */
+const http = require('http');
+const net = require('net');
+
+const VERSION = '0.1.0';
+const PROTOCOL_VERSION = 1;
+const HIGH_WATER = 4 * 1024 * 1024;
+const KIND = { DATA: 1, END: 2 };
+
+function parseArgs(argv) {
+ const o = { key: process.env.OV_CONNECT_KEY || '', port: 0, server: process.env.OV_CONNECT_SERVER || 'wss://connect.openvibe.website/tunnel', quiet: false };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--key') o.key = argv[++i] || '';
+ else if (a.startsWith('--key=')) o.key = a.slice(6);
+ else if (a === '--port') o.port = Number(argv[++i]);
+ else if (a.startsWith('--port=')) o.port = Number(a.slice(7));
+ else if (a === '--server') o.server = argv[++i] || o.server;
+ else if (a.startsWith('--server=')) o.server = a.slice(9);
+ else if (a === '--quiet' || a === '-q') o.quiet = true;
+ else if (a === '--help' || a === '-h') o.help = true;
+ else if (a === '--version') o.version = true;
+ else if (/^\d+$/.test(a) && !o.port) o.port = Number(a);
+ }
+ return o;
+}
+
+function encode(kind, id, payload) {
+ const body = payload ? Buffer.from(payload) : Buffer.alloc(0);
+ const out = Buffer.allocUnsafe(5 + body.length);
+ out.writeUInt8(kind, 0);
+ out.writeUInt32BE(id >>> 0, 1);
+ body.copy(out, 5);
+ return out;
+}
+
+function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ if (opts.version) { console.log(`ov-connect ${VERSION}`); return; }
+ if (opts.help || !opts.key || !opts.port) {
+ console.log('ov-connect: share a port on this computer at your OpenVibe Connect address.\n\n node ov-connect.js --key ovc_… --port 8080\n\nMake a share and its key at https://connect.openvibe.website');
+ process.exitCode = opts.help ? 0 : 2;
+ return;
+ }
+ if (typeof WebSocket !== 'function') { console.error('ov-connect needs Node 22 or newer (it uses the built-in WebSocket).'); process.exitCode = 2; return; }
+ if (!Number.isInteger(opts.port) || opts.port < 1 || opts.port > 65535) { console.error('The port is a number from 1 to 65535.'); process.exitCode = 2; return; }
+ const client = createClient(opts);
+ client.start();
+ const stop = () => { client.stop(); setTimeout(() => process.exit(0), 300).unref(); };
+ process.on('SIGINT', stop);
+ process.on('SIGTERM', stop);
+}
+
+/**
+ * The client itself (exported for tests): createClient({ key, port, server, quiet, log, localHost }) → { start, stop, onReady }
+ */
+function createClient(opts) {
+ const log = opts.log || ((line) => console.log(line));
+ const localHost = opts.localHost || 'localhost';
+ let ws = null;
+ let stopped = false;
+ let backoff = 1000;
+ let readyShare = null;
+ const streams = new Map(); // id → { kind: 'http'|'raw', req?, res?, sock? }
+ const listeners = { ready: [] };
+
+ const sendJson = (o) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify(o)); };
+ const sendData = (id, buf) => { if (ws && ws.readyState === 1 && buf.length) ws.send(encode(KIND.DATA, id, buf)); };
+ const sendEnd = (id) => { if (ws && ws.readyState === 1) ws.send(encode(KIND.END, id)); };
+ const congested = () => ws && ws.bufferedAmount > HIGH_WATER;
+ function whenDrained(fn) { const tick = () => { if (!congested()) fn(); else setTimeout(tick, 10); }; setTimeout(tick, 10); }
+ function abort(id, code, message) { sendJson({ t: 'abort', id, code, message: String(message || '').slice(0, 200) }); cleanup(id); }
+ function cleanup(id) {
+ const s = streams.get(id);
+ if (!s) return;
+ streams.delete(id);
+ try { if (s.req) s.req.destroy(); } catch { /* gone */ }
+ try { if (s.sock) s.sock.destroy(); } catch { /* gone */ }
+ }
+
+ function onRequest(m) {
+ const started = Date.now();
+ const flat = [];
+ for (const [k, v] of Array.isArray(m.headers) ? m.headers : []) flat.push(k, v);
+ let req;
+ try {
+ req = http.request({ host: localHost, port: opts.port, method: m.method, path: m.path, headers: flat, setHost: false });
+ } catch (err) {
+ sendJson({ t: 'abort', id: m.id, code: 'local_error', message: String(err && err.code || 'bad request') });
+ return;
+ }
+ const s = { kind: 'http', req, res: null };
+ streams.set(m.id, s);
+ req.on('response', (res) => {
+ s.res = res;
+ const headers = [];
+ for (let i = 0; i + 1 < res.rawHeaders.length; i += 2) headers.push([res.rawHeaders[i], res.rawHeaders[i + 1]]);
+ sendJson({ t: 'res', id: m.id, status: res.statusCode, headers });
+ if (!opts.quiet) log(`${m.method} ${String(m.path).slice(0, 120)} → ${res.statusCode} (${Date.now() - started} ms)`);
+ res.on('data', (chunk) => {
+ sendData(m.id, chunk);
+ if (congested()) { res.pause(); whenDrained(() => res.resume()); }
+ });
+ res.on('end', () => { sendEnd(m.id); streams.delete(m.id); });
+ res.on('error', () => abort(m.id, 'local_error', 'the response broke off'));
+ });
+ req.on('error', (err) => {
+ if (!streams.has(m.id)) return;
+ const refused = err && (err.code === 'ECONNREFUSED' || err.code === 'EADDRNOTAVAIL');
+ if (!opts.quiet) log(`${m.method} ${String(m.path).slice(0, 120)} → ${refused ? `nothing listening on port ${opts.port}` : err.message}`);
+ abort(m.id, refused ? 'local_refused' : 'local_error', err && err.code);
+ });
+ req.on('drain', () => sendJson({ t: 'resume', id: m.id }));
+ }
+
+ function onRaw(m) {
+ const sock = net.connect({ host: localHost, port: opts.port });
+ const s = { kind: 'raw', sock, connected: false, buffered: [] };
+ streams.set(m.id, s);
+ sock.on('connect', () => {
+ s.connected = true;
+ sock.write(String(m.head));
+ for (const b of s.buffered) sock.write(b);
+ s.buffered = [];
+ if (s.ended) sock.end();
+ });
+ sock.on('data', (chunk) => {
+ sendData(m.id, chunk);
+ if (congested()) { sock.pause(); whenDrained(() => sock.resume()); }
+ });
+ sock.on('end', () => { sendEnd(m.id); });
+ sock.on('close', () => { if (streams.has(m.id)) { streams.delete(m.id); sendEnd(m.id); } });
+ sock.on('error', (err) => {
+ const refused = err && err.code === 'ECONNREFUSED';
+ abort(m.id, refused ? 'local_refused' : 'local_error', err && err.code);
+ });
+ sock.on('drain', () => sendJson({ t: 'resume', id: m.id }));
+ if (!opts.quiet) log(`upgrade ${String(m.head).split('\r\n')[0].slice(0, 120)}`);
+ }
+
+ function onBinary(buf) {
+ if (buf.length < 5) return;
+ const kind = buf.readUInt8(0);
+ const id = buf.readUInt32BE(1);
+ const s = streams.get(id);
+ if (!s) return;
+ if (kind === KIND.DATA) {
+ const payload = buf.subarray(5);
+ if (s.kind === 'http') { if (!s.req.write(payload)) sendJson({ t: 'pause', id }); }
+ else if (!s.connected) s.buffered.push(payload);
+ else if (!s.sock.write(payload)) sendJson({ t: 'pause', id });
+ } else if (kind === KIND.END) {
+ if (s.kind === 'http') s.req.end();
+ else if (s.connected) s.sock.end();
+ else s.ended = true;
+ }
+ }
+
+ function onControl(m) {
+ if (m.t === 'ready') {
+ backoff = 1000;
+ readyShare = m.share;
+ log(`Connected. ${m.share.url} → localhost:${opts.port} (only people you let in can open it)`);
+ for (const fn of listeners.ready) fn(m.share);
+ return;
+ }
+ if (m.t === 'error') { log(`The server refused: ${m.message}`); return; }
+ if (m.t === 'req') return onRequest(m);
+ if (m.t === 'raw') return onRaw(m);
+ const s = Number.isInteger(m.id) ? streams.get(m.id) : null;
+ if (!s) return;
+ if (m.t === 'abort') cleanup(m.id);
+ else if (m.t === 'pause') { if (s.res) s.res.pause(); if (s.sock) s.sock.pause(); }
+ else if (m.t === 'resume') { if (s.res) s.res.resume(); if (s.sock) s.sock.resume(); }
+ }
+
+ function connect() {
+ if (stopped) return;
+ ws = new WebSocket(opts.server);
+ ws.binaryType = 'arraybuffer';
+ ws.addEventListener('open', () => {
+ ws.send(JSON.stringify({ t: 'hello', v: PROTOCOL_VERSION, key: opts.key, client: `ov-connect/${VERSION}`, port: opts.port }));
+ });
+ ws.addEventListener('message', (ev) => {
+ if (typeof ev.data === 'string') {
+ let m;
+ try { m = JSON.parse(ev.data); } catch { return; }
+ if (m && typeof m === 'object') onControl(m);
+ } else onBinary(Buffer.from(ev.data));
+ });
+ ws.addEventListener('close', (ev) => {
+ for (const id of [...streams.keys()]) cleanup(id);
+ if (stopped) return;
+ if (ev.code === 4001) { log('Another ov-connect took over this share. Stopping here.'); stopped = true; return; }
+ if (ev.code === 4002 || ev.code === 4003) { log(`Stopped: ${ev.reason || 'the share or its key is no longer valid'}.`); stopped = true; process.exitCode = 1; return; }
+ const wait = backoff;
+ backoff = Math.min(backoff * 2, 30_000);
+ log(`Disconnected${ev.reason ? ` (${ev.reason})` : ''}. Trying again in ${Math.round(wait / 1000)} s…`);
+ setTimeout(connect, wait);
+ });
+ ws.addEventListener('error', () => { /* the close event follows */ });
+ }
+
+ return {
+ start() { stopped = false; connect(); },
+ stop() { stopped = true; for (const id of [...streams.keys()]) cleanup(id); try { if (ws) ws.close(1000, 'bye'); } catch { /* gone */ } },
+ onReady(fn) { listeners.ready.push(fn); if (readyShare) fn(readyShare); },
+ get share() { return readyShare; },
+ };
+}
+
+if (require.main === module) main();
+
+module.exports = { createClient, parseArgs, encode, VERSION };
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+'use strict';
+
+/**
+ * OpenVibe.Connect: the Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database and a mock Network.
+ *
+ * <label>--<user>.openvibe.website/* a share: the gate, then the tunnel (edge/proxy.js), before anything else
+ * /, /shares/*, /grant the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /ov-connect.js the client, served as it is in public/
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /internal/events account export/deletion (loopback only)
+ * WebSocket /tunnel a computer connecting a share (edge/tunnels.js); ctx.onUpgrade routes it
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, setRelease } = require('./render/layout');
+const { createShares } = require('./shares');
+const { createTunnels } = require('./edge/tunnels');
+const { createGate } = require('./edge/gate');
+const { createProxy, rawResponder } = require('./edge/proxy');
+
+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 (Network), log, valkey, accountSend (a stand-in for Network's internal
+ * routes; tests only), telemetrySink.
+ */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const s = opts.store || await openStore(config, { now: opts.now, log });
+
+ const keys = createKeyStore({ config, fetchImpl, log });
+ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
+ const principal = createPrincipal({ config, keys });
+ const shares = createShares(s, { config });
+ const tunnels = createTunnels({ shares, config, log });
+ const gate = createGate({ config, now: s.now });
+ const proxy = createProxy({ config, shares, tunnels, gate, log, now: s.now });
+
+ 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: Connect cannot answer account events'); });
+
+ const ctx = { config, s, keys, sso, principal, shares, tunnels, gate, proxy, accountData, accountSend, log };
+
+ /** A WebSocket upgrade: a share host goes through the gate; /tunnel on Connect's own host is a computer. */
+ ctx.onUpgrade = (req, socket, head) => {
+ if (proxy.isShareHost(req.headers.host)) { proxy.upgrade(req, socket, head).catch(() => socket.destroy()); return; }
+ if (String(req.url || '').split('?')[0] === '/tunnel') { tunnels.handleUpgrade(req, socket, head); return; }
+ socket.on('error', () => {});
+ const r = rawResponder(socket);
+ r.writeHead(404, { 'content-type': 'text/plain' });
+ r.end('No WebSocket here.');
+ };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+
+ // ── Shares first: a share host's every request belongs to the share (the gate, then the tunnel). Nothing else of
+ // Connect (its pages, its API, its sign-in) answers on a share's address. ──
+ app.use((req, res, next) => {
+ if (!proxy.isShareHost(req.headers.host)) return next();
+ return proxy.handle(req, res).catch((err) => {
+ log.error('[OpenVibe.Connect] share request failed:', err && err.message);
+ if (!res.headersSent) { res.statusCode = 502; res.end('Something went wrong on our side.'); } else res.destroy();
+ });
+ });
+
+ const release = require('openvibe-shared/release').createRelease({ service: 'connect', root: path.join(__dirname, '..') });
+ setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'connect', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'connect', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
+ app.use(ctx.telemetry.middleware());
+ app.locals.ctx = ctx;
+
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ // The grant sends the browser on to a share's own address.
+ 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-connect', name: 'OpenVibe Connect', version: VERSION, tunnels: tunnels.status() }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey, tunnels });
+ 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: 'connect', service: 'connect', host: 'connect.openvibe.website', name: 'OpenVibe Connect', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only; nginx answers 404 for /internal/) ──
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets (content-hashed ?v= → immutable); the client is a plain download ──
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.get('/ov-connect.js', (req, res) => {
+ res.set('Cache-Control', 'public, max-age=300');
+ res.type('application/javascript').sendFile(path.join(PUBLIC_DIR, 'ov-connect.js'));
+ });
+ 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.CONNECT_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.CONNECT_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => sendNotFound(req, res, ctx));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Connect]', 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 };
+}
+
+function sendNotFound(req, res, ctx) {
+ const views = require('./views');
+ const { send } = require('./render/layout');
+ return send(res, 404, {
+ viewer: req.viewer, config: ctx.config, path: req.originalUrl, title: 'Not found',
+ body: views.notFound(), active: '/',
+ });
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's Bearer token (the API).
+ *
+ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
+ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
+ * this module: token.no_key answers 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Connect] 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.Connect] 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.Connect] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: openvibe-sdk/sso (0.73.0) is the one module every site uses. This file is this
+ * site's thin wrapper around it — the routes, the middleware and the shapes this site's pages still read.
+ *
+ * GET /auth/login → Network /oauth/authorize with state and PKCE (S256); ?next, ?silent, ?prompt, ?login_hint
+ * GET /auth/callback → the state is compared in constant time, the code is exchanged server-side (client secret)
+ * GET /auth/logout → the cookies are cleared, the refresh token revoked (best effort)
+ * GET /auth/me → { user } for the shared navbar
+ * GET /auth/health → { ok, client }: does the Network accept this site's OAuth client?
+ * POST /auth/fedcm → the shared navbar's FedCM assertion, swapped for tokens
+ * POST /auth/refresh → the tokens are rotated with the refresh token
+ *
+ * The SDK recovers by itself once (a lost or expired flow, a code the Network no longer accepts) and shows a page
+ * with "Try again" when it cannot: no more a bare "The Network refused the sign-in code." A client the Network
+ * refuses is logged as a configuration error, and GET /auth/health answers 503 for it.
+ *
+ * The person's old cookie (connect_at) is read as the session until the next sign-in or refresh writes ov_token, so
+ * nobody is signed out by the move. The access token stays httpOnly: no script can read it (the navbar asks
+ * /auth/me). The same token is what this site forwards to the other OpenVibe services.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { createSsoClient, COOKIES, parseCookies, sanitizeNext } = require('openvibe-sdk/sso');
+
+const ACCESS_COOKIE = 'connect_at';
+const REFRESH_COOKIE = 'connect_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+
+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;
+}
+
+/** The authorize URL for a sign-in (pure; the SDK builds its own, this one stays for the site's tests). */
+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 }) {
+ // The one module. The site's own values, its own old cookies, and the audience it always verified with.
+ const sso = createSsoClient({
+ site: 'connect',
+ siteName: 'OpenVibe Connect',
+ baseUrl: config.baseUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ networkUrl: config.networkPublicUrl || config.networkUrl,
+ networkInternalUrl: config.networkInternalUrl || config.networkUrl,
+ issuer: config.networkIssuer || config.networkPublicUrl || config.networkUrl,
+ redirectUri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ audience: config.oauth.sessionAudience,
+ secureCookies: config.cookies.secure !== false,
+ httpOnlyAccess: true,
+ legacyCookies: { access: [ACCESS_COOKIE], refresh: [REFRESH_COOKIE] },
+ fetch: fetchImpl,
+ log,
+ });
+
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: true, path: '/' });
+ const nextOf = (value) => sanitizeNext(value, { baseUrl: config.baseUrl, networkUrl: config.networkUrl });
+
+ /** The person, in the shape this site's pages read (req.viewer). */
+ function viewerFrom(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ const username = typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+ return {
+ kind: 'user',
+ subject,
+ username,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (username || '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,
+ };
+ }
+
+ /** The public profile the navbar and the API read. */
+ const meOf = (claims) => ({
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ display_name: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : null,
+ subject_id: typeof claims.subject_id === 'string' ? claims.subject_id : null,
+ role: typeof claims.role === 'string' ? claims.role : 'user',
+ avatar_url: typeof claims.avatar_url === 'string' ? claims.avatar_url.slice(0, 500) : null,
+ profile_color: typeof claims.profile_color === 'string' ? claims.profile_color.slice(0, 16) : null,
+ });
+
+ /**
+ * The access token expired (or is gone) but the refresh token is still there: rotate it and write the SDK's own
+ * cookies, the same names and options the SDK uses. The old cookies are cleared.
+ */
+ async function refreshSession(req, res) {
+ const c = parseCookies(req);
+ const rt = c[COOKIES.refresh] || c[REFRESH_COOKIE];
+ if (!rt) return null;
+ let data = null;
+ try { data = await sso.tokenGrant({ grant_type: 'refresh_token', refresh_token: rt }); } catch { return null; }
+ if (!data || !data.access_token) return null;
+ const claims = await sso.verify(data.access_token);
+ if (!claims) return null;
+ res.cookie(COOKIES.access, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(COOKIES.refresh, data.refresh_token, { ...cookieBase(), path: '/auth', maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.clearCookie(ACCESS_COOKIE, { path: '/' });
+ res.clearCookie(REFRESH_COOKIE, { path: '/' });
+ return { claims, token: data.access_token };
+ }
+
+ /** req.user and req.token for a valid session; req.viewer keeps this site's pages working as they are. */
+ function middleware() {
+ const optional = sso.optionalAuth();
+ return (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ optional(req, res, async () => {
+ try {
+ if (req.user && req.token) { req.viewer = viewerFrom(req.user, req.token); return next(); }
+ const fresh = await refreshSession(req, res);
+ if (fresh) {
+ req.user = fresh.claims;
+ req.token = fresh.token;
+ req.viewer = viewerFrom(fresh.claims, fresh.token);
+ }
+ } catch (err) {
+ log.warn('[OpenVibe.Connect] session refresh failed:', (err && err.message) || err);
+ }
+ return next();
+ });
+ };
+ }
+
+ /** A call with the person's own token; a 401 refreshes the session once and tries again. */
+ async function asViewer(req, res, fn) {
+ const token = req.token || (req.viewer && req.viewer.token) || null;
+ if (!token) throw Object.assign(new Error('Not signed in'), { status: 401 });
+ try {
+ return await fn(token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refreshSession(req, res);
+ if (!fresh) throw err;
+ req.user = fresh.claims;
+ req.token = fresh.token;
+ req.viewer = viewerFrom(fresh.claims, fresh.token);
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+
+ // GET /auth/me: the shared navbar's answer. A guest is { user: null } (200); a session that does not verify
+ // is 401, as it always was here.
+ r.get('/me', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const c = parseCookies(req);
+ const presented = Boolean(c[COOKIES.access] || c[ACCESS_COOKIE] || c[COOKIES.refresh] || c[REFRESH_COOKIE] || req.get('authorization'));
+ if (!presented) return res.json({ user: null });
+ const claims = await sso.verify(sso.extractToken(req));
+ if (!claims) return res.status(401).json({ error: 'Not signed in' });
+ return res.json({ user: meOf(claims) });
+ });
+
+ // Sign-out stays a same-site action: another site cannot sign a person out.
+ 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(nextOf(req.query.next))}` : '';
+ 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.Connect?</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) => {
+ // Any other verb (a DELETE from another site) is refused like the POST is.
+ if (!sameOriginRequest(req)) return res.status(403).type('text/plain').send('Sign-out must come from this site.');
+ const c = parseCookies(req);
+ const rt = c[COOKIES.refresh] || c[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(() => {});
+ }
+ res.clearCookie(COOKIES.access, { path: '/' });
+ res.clearCookie(COOKIES.refresh, { path: '/auth' });
+ res.clearCookie(ACCESS_COOKIE, { path: '/' });
+ res.clearCookie(REFRESH_COOKIE, { path: '/' });
+ res.set('Clear-Site-Data', '"cache"');
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure !== false, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ return res.redirect(303, nextOf(req.query.next));
+ });
+
+ // The SDK's own routes: /login, /callback, /fedcm, /refresh, /health.
+ r.use(sso.router(express));
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant: (fields) => sso.tokenGrant(fields), pkcePair, client: sso, checkClient: (o) => sso.checkClient(o) };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * OpenVibe.Connect configuration (connect.openvibe.website). Every value comes from the environment (production:
+ * /etc/openvibe/connect.env, see .env.example). Only environment variable NAMES appear in code and docs; secrets are
+ * never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+const bool = (v, def = false) => (v == null ? def : v === '1' || v === 'true' || v === 'yes');
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5240);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://connect.openvibe.website' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'connect',
+ room: 'website',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: bool(env.CONNECT_DRILL),
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CONNECT_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:connect:' },
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ audience: env.CONNECT_AUDIENCE || 'openvibe.website',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'connect',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's account export/deletion route (comma-separated for rotation).
+ events: {
+ secrets: String(env.CONNECT_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.CONNECT_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.CONNECT_EVENTS_ENDPOINT || '',
+ },
+
+ // The shares. A share answers at https://<label>--<user>.<shareDomain>. The session secret signs the
+ // visitor's host-only cookie on a share host and is required in production (32+ characters).
+ shares: {
+ domain: String(env.CONNECT_SHARE_DOMAIN || 'openvibe.website').toLowerCase(),
+ scheme: env.CONNECT_SHARE_SCHEME || (isProduction ? 'https' : 'http'),
+ sessionSecret: env.CONNECT_SESSION_SECRET || (isProduction ? '' : 'development-only-session-secret-0123456789'),
+ sessionHours: Math.max(1, Math.min(72, int(env.CONNECT_SESSION_HOURS, 12))),
+ perPerson: Math.max(1, Math.min(50, int(env.CONNECT_SHARES_PER_PERSON, 5))),
+ // Per share: requests at once, a request body's size, and how long a request may take end to end.
+ maxStreams: Math.max(1, Math.min(1000, int(env.CONNECT_MAX_STREAMS, 64))),
+ maxBodyBytes: Math.max(1024, int(env.CONNECT_MAX_BODY_BYTES, 100 * 1024 * 1024)),
+ requestTimeoutMs: Math.max(1000, int(env.CONNECT_REQUEST_TIMEOUT_MS, 120_000)),
+ // A share's requests per minute, and the bytes a person's shares may move in a day (both directions).
+ perMinute: Math.max(1, int(env.CONNECT_REQUESTS_PER_MINUTE, 1200)),
+ bytesPerDay: Math.max(1024 * 1024, int(env.CONNECT_BYTES_PER_DAY, 10 * 1024 * 1024 * 1024)),
+ },
+ // The tunnel: how often the server pings an idle tunnel (Cloudflare closes a WebSocket idle for 100 s), and
+ // how long a client has to say hello.
+ tunnel: {
+ pingMs: Math.max(1000, int(env.CONNECT_PING_MS, 25_000)),
+ helloMs: Math.max(500, int(env.CONNECT_HELLO_MS, 10_000)),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Connect's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret: a share stores its owner's subject id, its address, its port and the hash
+ * of its connect key; a visit row is a count per share, person and day.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Connect] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'connect', 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: 'connect-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'connect', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx((t) => fn(t)),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+
+/**
+ * The tunnel's wire format, `ovconnect-ws/1`: one WebSocket per connected share, many requests at once over it.
+ *
+ * Text frames are JSON control messages:
+ * client → server { t: 'hello', key, v: 1, client, port }
+ * server → client { t: 'ready', v: 1, share: { id, host, url, mode } } or { t: 'error', code, message } then close
+ * server → client { t: 'req', id, method, path, headers: [[name, value], …] } an HTTP request
+ * server → client { t: 'raw', id, head } a WebSocket upgrade: the client
+ * opens a plain TCP connection to the local port, writes `head`, and from then on both sides
+ * only move bytes (the local server answers the 101 itself)
+ * client → server { t: 'res', id, status, headers: [[name, value], …] } the response head
+ * either way { t: 'pause', id } / { t: 'resume', id } backpressure on one stream
+ * either way { t: 'abort', id, code, message } the stream is over, badly
+ *
+ * Binary frames carry bodies: [kind: 1 byte][stream id: 4 bytes, big-endian][payload].
+ * kind 1 data kind 2 end (no payload)
+ *
+ * Stream ids are chosen by the server (1, 2, 3 …) and never reused on one tunnel.
+ */
+const KIND = { DATA: 1, END: 2 };
+const VERSION = 1;
+const PROTOCOL = 'ovconnect-ws/1';
+
+function encode(kind, id, payload) {
+ const body = payload ? (Buffer.isBuffer(payload) ? payload : Buffer.from(payload)) : Buffer.alloc(0);
+ const out = Buffer.allocUnsafe(5 + body.length);
+ out.writeUInt8(kind, 0);
+ out.writeUInt32BE(id >>> 0, 1);
+ body.copy(out, 5);
+ return out;
+}
+
+function decode(buf) {
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
+ if (b.length < 5) return null;
+ const kind = b.readUInt8(0);
+ if (kind !== KIND.DATA && kind !== KIND.END) return null;
+ return { kind, id: b.readUInt32BE(1), payload: b.subarray(5) };
+}
+
+/** Header pairs as sent on the wire: lowercase names, string values, nothing longer than 16 KB in all. */
+function headerPairs(rawHeaders) {
+ const out = [];
+ let size = 0;
+ for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
+ const name = String(rawHeaders[i]).toLowerCase();
+ const value = String(rawHeaders[i + 1]);
+ size += name.length + value.length;
+ if (size > 64 * 1024) break;
+ out.push([name, value]);
+ }
+ return out;
+}
+
+module.exports = { KIND, VERSION, PROTOCOL, encode, decode, headerPairs };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+
+/**
+ * The gate in front of every share: who may open it, and how a visitor's browser proves it on a share host.
+ *
+ * A share host is another origin (agents--alex.openvibe.website), and openvibe.website is not on the Public Suffix
+ * List yet, so the gate keeps every cookie host-only and signs it itself:
+ *
+ * 1. A visitor without a valid cookie is sent to https://connect.openvibe.website/grant?share=<id>&next=<path>.
+ * 2. There, signed in to OpenVibe (sign-in happens first if needed), the share's policy is checked. A person it lets
+ * in gets a one-time code (60 seconds, used once, bound to that share) and goes back to
+ * https://<share host>/__ovc/callback?code=…&next=….
+ * 3. The share host swaps the code for its own cookie: `__Host-ovc` (Secure, HttpOnly, SameSite=Lax, Path=/, no
+ * Domain), an HMAC-signed { share, subject, username, policy version, expiry }.
+ *
+ * A policy change bumps the share's policy_version, so every cookie made before it stops working on the next request
+ * and its holder goes through the grant again (the owner never notices: they are let straight back in).
+ */
+const crypto = require('crypto');
+
+const CODE_TTL_MS = 60_000;
+
+function createGate({ config, now = () => Date.now() }) {
+ const secret = config.shares.sessionSecret;
+ if (!secret || secret.length < 32) throw new Error('CONNECT_SESSION_SECRET must be set (32+ characters): it signs the visitors\' cookies on share hosts');
+ const cookieName = config.cookies.secure !== false ? '__Host-ovc' : 'ovc';
+ const codes = new Map(); // code → { shareId, subject, username, exp }
+
+ const sweeper = setInterval(() => {
+ const t = now();
+ for (const [k, v] of codes) if (v.exp <= t) codes.delete(k);
+ }, 30_000);
+ sweeper.unref();
+
+ const mac = (data) => crypto.createHmac('sha256', secret).update(data).digest('base64url');
+
+ function sign({ shareId, subject, username, policyVersion }) {
+ const exp = now() + config.shares.sessionHours * 3600_000;
+ const data = Buffer.from(JSON.stringify({ s: shareId, u: subject, n: username || null, v: policyVersion, e: exp })).toString('base64url');
+ return `${data}.${mac(data)}`;
+ }
+
+ /** The cookie's claims when it is ours, unexpired and for this share at this policy version; else null. */
+ function verify(value, share) {
+ if (typeof value !== 'string' || value.length > 2048) return null;
+ const dot = value.lastIndexOf('.');
+ if (dot <= 0) return null;
+ const data = value.slice(0, dot);
+ const sig = value.slice(dot + 1);
+ const want = mac(data);
+ if (sig.length !== want.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
+ let c;
+ try { c = JSON.parse(Buffer.from(data, 'base64url').toString('utf8')); } catch { return null; }
+ if (!c || c.s !== share.id || c.v !== share.policy_version || !(c.e > now())) return null;
+ return { subject: c.u, username: c.n };
+ }
+
+ function cookieHeader(value) {
+ const maxAge = config.shares.sessionHours * 3600;
+ return `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${config.cookies.secure !== false ? '; Secure' : ''}`;
+ }
+ const clearCookieHeader = () => `${cookieName}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${config.cookies.secure !== false ? '; Secure' : ''}`;
+
+ /** Our cookie's value from a Cookie header, and the header without it (what the local app receives). */
+ function splitCookies(header) {
+ let ours = null;
+ const rest = [];
+ for (const part of String(header || '').split(';')) {
+ const p = part.trim();
+ if (!p) continue;
+ const eq = p.indexOf('=');
+ const name = eq < 0 ? p : p.slice(0, eq);
+ if (name === cookieName || name === '__Host-ovc' || name === 'ovc') { if (name === cookieName) ours = eq < 0 ? '' : p.slice(eq + 1); continue; }
+ rest.push(p);
+ }
+ return { ours, rest: rest.join('; ') };
+ }
+
+ function mintCode({ shareId, subject, username }) {
+ const code = crypto.randomBytes(24).toString('base64url');
+ codes.set(code, { shareId, subject, username, exp: now() + CODE_TTL_MS });
+ return code;
+ }
+ function redeemCode(code, shareId) {
+ if (typeof code !== 'string') return null;
+ const v = codes.get(code);
+ if (!v) return null;
+ codes.delete(code);
+ if (v.exp <= now() || v.shareId !== shareId) return null;
+ return v;
+ }
+
+ /** A path on the share host to go back to: a local path only (never another host, never "//"). */
+ function safeNext(next) {
+ const s = typeof next === 'string' ? next : '/';
+ if (!s.startsWith('/') || s.startsWith('//') || s.startsWith('/\\') || /[\r\n\t]/.test(s) || s.length > 2000) return '/';
+ if (s.startsWith('/__ovc/')) return '/';
+ return s;
+ }
+
+ return { sign, verify, cookieHeader, clearCookieHeader, splitCookies, mintCode, redeemCode, safeNext, cookieName, stop: () => clearInterval(sweeper), CODE_TTL_MS };
+}
+
+module.exports = { createGate, CODE_TTL_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,384 @@
@@ -0,0 +1,384 @@
+'use strict';
+
+/**
+ * The visitor's side of a share: every request to https://<label>--<user>.openvibe.website.
+ *
+ * handle(req, res) an HTTP request (SSE and streaming bodies included)
+ * upgrade(req, socket, head) a WebSocket upgrade: after the gate, a byte pipe to the local port
+ * isShareHost(host) is this host a share's (the naming rule only)
+ * flush() write the counters gathered in memory (every 30 s, and on stop)
+ *
+ * The share comes from the Host header nginx sets ($host), never from X-Forwarded-Host or any other header a
+ * visitor controls. The local app receives the request with Host `localhost:<port>`, the share's host in
+ * X-Forwarded-Host, the visitor's address in X-Forwarded-For, and who they are in X-OpenVibe-User and
+ * X-OpenVibe-Subject (headers of those names from the visitor are dropped). The gate's own cookie never reaches it.
+ *
+ * Responses: a Set-Cookie's Domain attribute is removed (until openvibe.website is on the Public Suffix List, a
+ * cookie for .openvibe.website would reach every other share and every Space site), and nothing a share sends may be
+ * kept by Cloudflare's cache (Cloudflare-CDN-Cache-Control: no-store; Cache-Control is made private): a cached copy
+ * would be served past the gate.
+ */
+const { parseHost } = require('../shares');
+
+const HOP = new Set(['connection', 'keep-alive', 'proxy-connection', 'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
+const DROP_IN = /^(host|cookie|forwarded|x-forwarded-.*|x-real-ip|x-openvibe-.*|cf-.*|cdn-loop|true-client-ip)$/;
+const SHARE_CACHE_MS = 5000;
+
+/**
+ * The app's Cache-Control, made safe to pass a CDN: `public` and `s-maxage` are dropped and `private` is added, so
+ * the browser may still keep what the app says it may, and no shared cache ever does. No header → `private, no-cache`.
+ */
+function privateCache(value) {
+ const parts = String(value || '').split(',').map((p) => p.trim()).filter(Boolean)
+ .filter((p) => !/^(public|s-maxage\s*=.*|proxy-revalidate)$/i.test(p));
+ if (!parts.length) return 'private, no-cache';
+ if (parts.some((p) => /^(private|no-store)$/i.test(p))) return parts.join(', ');
+ return ['private', ...parts].join(', ');
+}
+
+function createProxy({ config, shares, tunnels, gate, log = console, now = () => Date.now() }) {
+ const domain = config.shares.domain;
+ const cache = new Map(); // host → { share, at }
+ const windows = new Map(); // share id → { start, count }
+ const ownerBytes = new Map(); // owner subject → { day, bytes }
+ let counters = {}; // share id → { requests, bytesIn, bytesOut, visits }
+
+ const isShareHost = (host) => Boolean(parseHost(host, domain));
+ const today = () => new Date(now()).toISOString().slice(0, 10);
+ const grantUrl = (share, next) => `${config.baseUrl}/grant?share=${encodeURIComponent(share.id)}&next=${encodeURIComponent(gate.safeNext(next))}`;
+
+ async function lookup(host) {
+ const hit = cache.get(host);
+ if (hit && now() - hit.at < SHARE_CACHE_MS) return hit.share;
+ const share = await shares.byHost(host);
+ cache.set(host, { share, at: now() });
+ if (cache.size > 5000) cache.delete(cache.keys().next().value);
+ return share;
+ }
+ /** Forget what is cached for a share (a policy change, a new key, a revoke). */
+ function forget(host) { cache.delete(String(host || '').toLowerCase()); }
+
+ function counter(shareId) {
+ if (!counters[shareId]) counters[shareId] = { requests: 0, bytesIn: 0, bytesOut: 0, visits: {} };
+ return counters[shareId];
+ }
+ function visit(share, who, { denied = false } = {}) {
+ const c = counter(share.id);
+ if (!denied) c.requests++;
+ if (!who || !who.subject) return;
+ const v = c.visits[who.subject] || (c.visits[who.subject] = { username: who.username || null, requests: 0, denied: 0 });
+ if (denied) v.denied++; else v.requests++;
+ }
+ function addBytes(share, dir, n) {
+ if (!n) return;
+ const c = counter(share.id);
+ if (dir === 'in') c.bytesIn += n; else c.bytesOut += n;
+ const d = today();
+ const o = ownerBytes.get(share.owner_subject);
+ if (!o || o.day !== d) ownerBytes.set(share.owner_subject, { day: d, bytes: n });
+ else o.bytes += n;
+ }
+ const overBytes = (share) => { const o = ownerBytes.get(share.owner_subject); return Boolean(o && o.day === today() && o.bytes >= config.shares.bytesPerDay); };
+ function overRate(share) {
+ const t = now();
+ const w = windows.get(share.id);
+ if (!w || t - w.start >= 60_000) { windows.set(share.id, { start: t, count: 1 }); return false; }
+ w.count++;
+ return w.count > config.shares.perMinute;
+ }
+
+ async function flush() {
+ const batch = counters;
+ counters = {};
+ if (!Object.keys(batch).length) return;
+ try { await shares.flushCounters(batch, today()); } catch (err) { log.warn('[OpenVibe.Connect] counters not written:', err.message); }
+ }
+ const flusher = setInterval(() => { flush().catch(() => {}); }, 30_000);
+ flusher.unref();
+
+ function forwardHeaders(req, share, who, port, { raw = false } = {}) {
+ const out = [];
+ const pairs = req.rawHeaders;
+ for (let i = 0; i + 1 < pairs.length; i += 2) {
+ const name = String(pairs[i]).toLowerCase();
+ if (DROP_IN.test(name)) continue;
+ if (HOP.has(name) && !(raw && (name === 'connection' || name === 'upgrade'))) continue;
+ out.push([name, String(pairs[i + 1])]);
+ }
+ const { rest } = gate.splitCookies(req.headers.cookie);
+ if (rest) out.push(['cookie', rest]);
+ out.push(['host', `localhost:${port}`]);
+ out.push(['x-forwarded-host', share.host]);
+ out.push(['x-forwarded-proto', config.shares.scheme]);
+ const ip = String(req.headers['x-real-ip'] || req.socket.remoteAddress || '').slice(0, 64);
+ if (ip) out.push(['x-forwarded-for', ip]);
+ out.push(['x-openvibe-user', String(who.username || '')]);
+ out.push(['x-openvibe-subject', String(who.subject || '')]);
+ return out;
+ }
+
+ function responseHeaders(pairs) {
+ const out = {};
+ const cookies = [];
+ let cacheControl = null;
+ for (const pair of Array.isArray(pairs) ? pairs : []) {
+ if (!Array.isArray(pair) || pair.length !== 2) continue;
+ const name = String(pair[0]).toLowerCase();
+ const value = String(pair[1]);
+ if (!/^[!#$%&'*+.^_`|~0-9a-z-]{1,100}$/.test(name) || /[\r\n\0]/.test(value)) continue;
+ if (HOP.has(name) || name === 'cloudflare-cdn-cache-control' || name === 'cdn-cache-control' || name === 'x-robots-tag') continue;
+ if (name === 'set-cookie') {
+ const c = scrubCookie(value);
+ if (c) cookies.push(c);
+ continue;
+ }
+ if (name === 'cache-control') { cacheControl = value; continue; }
+ if (out[name] === undefined) out[name] = value;
+ else out[name] = Array.isArray(out[name]) ? [...out[name], value] : [out[name], value];
+ }
+ if (cookies.length) out['set-cookie'] = cookies;
+ out['cache-control'] = privateCache(cacheControl);
+ out['cloudflare-cdn-cache-control'] = 'no-store';
+ out['x-robots-tag'] = 'noindex, nofollow';
+ return out;
+ }
+
+ function scrubCookie(value) {
+ const parts = value.split(';');
+ const name = parts[0].split('=')[0].trim();
+ if (name === gate.cookieName || name === '__Host-ovc' || name === 'ovc') return null;
+ return parts.filter((p, i) => i === 0 || !/^\s*domain\s*=/i.test(p)).join(';');
+ }
+
+ // ── Pages the gate answers itself (self-contained: a share host loads nothing from anywhere else) ──
+ function sendPage(res, status, title, text, { link = null, headers = {} } = {}) {
+ if (res.headersSent) { res.destroy(); return; }
+ res.writeHead(status, {
+ 'content-type': 'text/html; charset=utf-8',
+ 'cache-control': 'no-store',
+ 'cloudflare-cdn-cache-control': 'no-store',
+ 'x-robots-tag': 'noindex, nofollow',
+ 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
+ 'x-content-type-options': 'nosniff',
+ 'referrer-policy': 'no-referrer',
+ ...headers,
+ });
+ res.end(page(title, text, link));
+ }
+ function wantsHtml(req) { return /text\/html/.test(String(req.headers.accept || '')) && (req.method === 'GET' || req.method === 'HEAD'); }
+ function sendProblem(req, res, status, code, title, text, extra = {}) {
+ if (wantsHtml(req)) return sendPage(res, status, title, text, extra);
+ if (res.headersSent) { res.destroy(); return undefined; }
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'x-robots-tag': 'noindex', ...(extra.headers || {}) });
+ res.end(JSON.stringify({ error: code, message: text, ...(extra.json || {}) }));
+ return undefined;
+ }
+
+ /** The share, the person and the tunnel for a request, or an answer already sent. */
+ async function admit(req, res, share, parsedPath) {
+ const { ours } = gate.splitCookies(req.headers.cookie);
+ const who = ours ? gate.verify(ours, share) : null;
+ if (!who) {
+ const url = grantUrl(share, parsedPath);
+ if (wantsHtml(req)) { res.writeHead(302, { location: url, 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store' }); res.end(); return null; }
+ sendProblem(req, res, 401, 'sign_in_required', 'Sign in first', 'This share asks every visitor to sign in with OpenVibe.', { json: { sign_in: url } });
+ return null;
+ }
+ if (overRate(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'rate_limited', 'Too many requests', 'This share had too many requests in the last minute. Try again shortly.', { headers: { 'retry-after': '30' } }); return null; }
+ if (overBytes(share)) { visit(share, who, { denied: true }); sendProblem(req, res, 429, 'over_daily_limit', 'Over today\'s limit', `${share.owner_username}'s shares moved as much data as they can today. They open again at midnight UTC.`); return null; }
+ const tunnel = tunnels.get(share.id);
+ if (!tunnel) {
+ sendProblem(req, res, 502, 'offline', `${share.owner_username}'s computer is not connected`, `Nothing is connected to ${share.host} right now. If it is yours, start ov-connect on your computer.`, { link: { href: `${config.baseUrl}/shares/${share.id}`, text: 'Open the share page' }, headers: { 'retry-after': '5' } });
+ return null;
+ }
+ if (tunnel.busy) { sendProblem(req, res, 503, 'busy', 'Busy', 'This share is answering as many requests as it can at once. Try again in a moment.', { headers: { 'retry-after': '2' } }); return null; }
+ return { who, tunnel };
+ }
+
+ async function resolve(req, res) {
+ const parsed = parseHost(req.headers.host, domain);
+ if (!parsed) return null;
+ let share;
+ try { share = await lookup(parsed.host); } catch (err) {
+ log.warn('[OpenVibe.Connect] share lookup failed:', err.message);
+ sendProblem(req, res, 503, 'unavailable', 'Try again in a moment', 'OpenVibe Connect could not look this share up just now.');
+ return null;
+ }
+ if (!share) { sendProblem(req, res, 404, 'no_share', 'No share here', `Nothing is shared at ${parsed.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
+ if (share.status !== 'active') { sendProblem(req, res, 410, 'closed', 'This share is closed', `${share.owner_username} closed ${share.host}.`, { link: { href: config.baseUrl, text: 'What is OpenVibe Connect?' } }); return null; }
+ return share;
+ }
+
+ async function handle(req, res) {
+ const share = await resolve(req, res);
+ if (!share) return;
+ const url = String(req.url || '/');
+ const path = url.split('?')[0];
+
+ if (path === '/__ovc/callback') {
+ const q = new URL(url, 'http://x').searchParams;
+ const v = gate.redeemCode(q.get('code'), share.id);
+ if (!v) return sendPage(res, 400, 'That link ran out', 'Sign-in links work once, for a minute.', { link: { href: grantUrl(share, q.get('next') || '/'), text: 'Try again' } });
+ const cookie = gate.sign({ shareId: share.id, subject: v.subject, username: v.username, policyVersion: share.policy_version });
+ res.writeHead(303, { location: gate.safeNext(q.get('next')), 'set-cookie': gate.cookieHeader(cookie), 'cache-control': 'no-store', 'cloudflare-cdn-cache-control': 'no-store', 'referrer-policy': 'no-referrer' });
+ return res.end();
+ }
+ if (path === '/__ovc/signout') {
+ return sendPage(res, 200, 'Signed out of this share', `You are signed out of ${share.host}. Your OpenVibe sign-in is untouched.`, { headers: { 'set-cookie': gate.clearCookieHeader() } });
+ }
+
+ const admitted = await admit(req, res, share, url);
+ if (!admitted) return;
+ const { who, tunnel } = admitted;
+ visit(share, who);
+
+ const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
+ let finished = false;
+ let headTimer = null;
+ let received = 0;
+ const id = tunnel.open({
+ onRes(m) {
+ clearTimeout(headTimer);
+ const status = Number(m.status);
+ if (!Number.isInteger(status) || status < 100 || status > 599 || status === 101) {
+ tunnel.abort(id, 'bad_status');
+ return sendProblem(req, res, 502, 'bad_response', 'The app answered something odd', 'The app on the other end sent a response OpenVibe Connect cannot pass on.');
+ }
+ try { res.writeHead(status, responseHeaders(m.headers)); } catch { tunnel.abort(id, 'bad_headers'); res.destroy(); }
+ return undefined;
+ },
+ onData(buf) {
+ addBytes(share, 'out', buf.length);
+ if (!res.write(buf)) {
+ tunnel.send({ t: 'pause', id });
+ res.once('drain', () => tunnel.send({ t: 'resume', id }));
+ }
+ },
+ onEnd() { finished = true; tunnel.release(id); clearTimeout(headTimer); res.end(); },
+ onAbort(m) {
+ finished = true;
+ clearTimeout(headTimer);
+ if (res.headersSent) { res.destroy(); return; }
+ if (m && m.code === 'local_refused') {
+ sendProblem(req, res, 502, 'local_refused', 'Nothing is listening', `${share.owner_username}'s computer is connected, but nothing answered on port ${port}.`);
+ } else if (m && m.code === 'tunnel_closed') {
+ sendProblem(req, res, 502, 'offline', 'The connection dropped', `${share.owner_username}'s computer disconnected while answering. Try again in a moment.`, { headers: { 'retry-after': '5' } });
+ } else {
+ sendProblem(req, res, 502, 'local_error', 'The app did not answer', 'The app on the other end stopped before it answered.');
+ }
+ },
+ onPause() { req.pause(); },
+ onResume() { req.resume(); },
+ });
+ headTimer = setTimeout(() => {
+ if (res.headersSent || finished) return;
+ finished = true;
+ tunnel.abort(id, 'timeout');
+ sendProblem(req, res, 504, 'timeout', 'That took too long', `The app on ${share.owner_username}'s computer did not answer in ${Math.round(config.shares.requestTimeoutMs / 1000)} seconds.`);
+ }, config.shares.requestTimeoutMs);
+ headTimer.unref();
+
+ tunnel.send({ t: 'req', id, method: req.method, path: url, headers: forwardHeaders(req, share, who, port) });
+ req.on('data', (chunk) => {
+ received += chunk.length;
+ addBytes(share, 'in', chunk.length);
+ if (received > config.shares.maxBodyBytes) {
+ if (!finished) { finished = true; tunnel.abort(id, 'too_large'); sendProblem(req, res, 413, 'too_large', 'Too large', 'That request is larger than a share takes.'); }
+ req.destroy();
+ return;
+ }
+ tunnel.data(id, chunk);
+ if (tunnel.congested) { req.pause(); tunnel.drained().then(() => req.resume()); }
+ });
+ req.on('end', () => { if (!finished) tunnel.end(id); });
+ res.on('close', () => { if (!finished && !res.writableFinished) { finished = true; clearTimeout(headTimer); tunnel.abort(id, 'visitor_gone'); } });
+ }
+
+ /** A WebSocket upgrade on a share host: the gate, then the raw bytes both ways. */
+ async function upgrade(req, socket, head) {
+ socket.on('error', () => {});
+ // A tiny response object over the raw socket, so the gate's answers read the same here.
+ const fake = rawResponder(socket);
+ const share = await resolve(req, fake);
+ if (!share) return;
+ const admitted = await admit(req, fake, share, String(req.url || '/'));
+ if (!admitted) return;
+ const { who, tunnel } = admitted;
+ visit(share, who);
+ const port = (tunnel.clientInfo && tunnel.clientInfo.port) || share.local_port;
+
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
+ for (const [k, v] of forwardHeaders(req, share, who, port, { raw: true })) lines.push(`${k}: ${v.replace(/[\r\n]/g, ' ')}`);
+ const headText = `${lines.join('\r\n')}\r\n\r\n`;
+ let opened = false;
+ const id = tunnel.open({
+ onData(buf) {
+ opened = true;
+ addBytes(share, 'out', buf.length);
+ if (!socket.write(buf)) { tunnel.send({ t: 'pause', id }); socket.once('drain', () => tunnel.send({ t: 'resume', id })); }
+ },
+ onEnd() { tunnel.release(id); socket.end(); },
+ onAbort(m) {
+ if (!opened && m && m.code === 'local_refused') {
+ socket.end(`HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/plain\r\nconnection: close\r\n\r\nNothing answered on port ${port}.`);
+ } else socket.destroy();
+ },
+ onPause() { socket.pause(); },
+ onResume() { socket.resume(); },
+ });
+ tunnel.send({ t: 'raw', id, head: headText });
+ if (head && head.length) { addBytes(share, 'in', head.length); tunnel.data(id, head); }
+ socket.on('data', (chunk) => {
+ addBytes(share, 'in', chunk.length);
+ tunnel.data(id, chunk);
+ if (tunnel.congested) { socket.pause(); tunnel.drained().then(() => socket.resume()); }
+ });
+ socket.on('end', () => tunnel.end(id));
+ socket.on('close', () => { if (tunnel.streams.has(id)) tunnel.abort(id, 'visitor_gone'); });
+ }
+
+ function stop() { clearInterval(flusher); return flush(); }
+
+ /** The grant refused someone: counted on the share page as "turned away". */
+ function denied(share, who) { visit(share, who, { denied: true }); }
+
+ return { handle, upgrade, isShareHost, flush, forget, denied, stop, responseHeaders, forwardHeaders };
+}
+
+/** Write an HTTP answer straight to an upgrade's socket (the gate's 302/401/404/502 before any tunnel opens). */
+function rawResponder(socket) {
+ let sent = false;
+ let status = 200;
+ let headers = {};
+ return {
+ get headersSent() { return sent; },
+ writeHead(s, h) { status = s; headers = h || {}; sent = true; },
+ end(body = '') {
+ const lines = [`HTTP/1.1 ${status} ${STATUS_TEXT[status] || 'Error'}`];
+ const b = Buffer.from(String(body));
+ for (const [k, v] of Object.entries(headers)) for (const one of [].concat(v)) lines.push(`${k}: ${one}`);
+ lines.push(`content-length: ${b.length}`, 'connection: close');
+ socket.end(Buffer.concat([Buffer.from(`${lines.join('\r\n')}\r\n\r\n`), b]));
+ },
+ destroy() { socket.destroy(); },
+ };
+}
+const STATUS_TEXT = { 302: 'Found', 400: 'Bad Request', 401: 'Unauthorized', 404: 'Not Found', 410: 'Gone', 429: 'Too Many Requests', 502: 'Bad Gateway', 503: 'Service Unavailable', 504: 'Gateway Timeout' };
+
+const escapeHtml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** The gate's own page: plain, readable in both themes, no outside resources. */
+function page(title, text, link) {
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex"><title>${escapeHtml(title)} · OpenVibe Connect</title>
+<style>:root{color-scheme:light dark;--bg:#f6f7fb;--fg:#141824;--muted:#5b6275;--card:#fff;--line:#dfe3ee;--accent:#4B53C2}
+@media (prefers-color-scheme:dark){:root{--bg:#0d131d;--fg:#e8ecf5;--muted:#9aa3b8;--card:#141c2a;--line:#253047;--accent:#8EA0FF}}
+body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;padding:24px max(16px,env(safe-area-inset-left))}
+main{max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:16px;padding:28px}
+h1{font-size:1.35rem;margin:0 0 .5rem;text-wrap:balance}p{margin:0 0 1rem;color:var(--muted)}
+a{display:inline-block;color:var(--accent);font-weight:600;text-decoration:none;padding:10px 0;min-height:24px}a:hover{text-decoration:underline}
+.mark{font-size:.8rem;letter-spacing:.06em;text-transform:uppercase;color:var(--muted);margin-bottom:12px}</style></head>
+<body><main><div class="mark">OpenVibe Connect</div><h1>${escapeHtml(title)}</h1><p>${escapeHtml(text)}</p>${link ? `<a href="${escapeHtml(link.href)}">${escapeHtml(link.text)}</a>` : ''}</main></body></html>`;
+}
+
+module.exports = { createProxy, page, rawResponder };
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+
+/**
+ * The tunnels: one WebSocket per connected share, opened by the client (public/ov-connect.js) to
+ * wss://connect.openvibe.website/tunnel and authenticated by its first message, { t: 'hello', key }.
+ *
+ * createTunnels({ shares, config, log }) → { handleUpgrade(req, socket, head), get(shareId), close(shareId, code, why),
+ * closeAll(), status() }
+ * tunnel.open({ onRes, onData, onEnd, onAbort, onPause, onResume }) → stream id
+ * tunnel.send(obj), tunnel.data(id, buf), tunnel.end(id), tunnel.abort(id, code, message), tunnel.release(id)
+ *
+ * A newer connection for the same share replaces the older one (close code 4001), so a laptop that reconnects after
+ * sleep never leaves a dead tunnel behind. A wrong key is 4002; a revoked share or a rotated key closes its tunnel
+ * (4003); a tunnel that stops answering pings is 4008. The server
+ * pings every CONNECT_PING_MS so Cloudflare does not close an idle tunnel, and a tunnel that misses two pings is
+ * closed.
+ */
+const { WebSocketServer } = require('ws');
+const frames = require('./frames');
+
+const HIGH_WATER = 4 * 1024 * 1024;
+
+class Tunnel {
+ constructor(ws, share, { log, maxStreams }) {
+ this.ws = ws;
+ this.share = share;
+ this.log = log;
+ this.maxStreams = maxStreams;
+ this.streams = new Map();
+ this.nextId = 1;
+ this.connectedAt = Date.now();
+ this.alive = true;
+ this.closed = false;
+ this.clientInfo = null;
+ }
+
+ get busy() { return this.streams.size >= this.maxStreams; }
+ get congested() { return this.ws.bufferedAmount > HIGH_WATER; }
+
+ open(handlers) {
+ const id = this.nextId++;
+ this.streams.set(id, handlers);
+ return id;
+ }
+ release(id) { this.streams.delete(id); }
+
+ send(obj) { if (!this.closed && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj)); }
+ data(id, buf) { if (!this.closed && this.ws.readyState === 1 && buf && buf.length) this.ws.send(frames.encode(frames.KIND.DATA, id, buf)); }
+ end(id) { if (!this.closed && this.ws.readyState === 1) this.ws.send(frames.encode(frames.KIND.END, id)); }
+ abort(id, code = 'aborted', message = '') {
+ this.send({ t: 'abort', id, code, message: String(message).slice(0, 200) });
+ this.release(id);
+ }
+
+ /** Wait until the socket's send buffer drains below the high-water mark (or the tunnel closes). */
+ drained() {
+ if (!this.congested || this.closed) return Promise.resolve();
+ return new Promise((resolve) => {
+ const tick = () => { if (!this.congested || this.closed) resolve(); else setTimeout(tick, 10); };
+ setTimeout(tick, 10);
+ });
+ }
+
+ onMessage(message, isBinary) {
+ if (isBinary) {
+ const f = frames.decode(message);
+ if (!f) return;
+ const h = this.streams.get(f.id);
+ if (!h) return;
+ if (f.kind === frames.KIND.DATA) { if (h.onData) h.onData(f.payload); } else if (h.onEnd) h.onEnd();
+ return;
+ }
+ let m;
+ try { m = JSON.parse(String(message)); } catch { return; }
+ if (!m || typeof m !== 'object') return;
+ const h = Number.isInteger(m.id) ? this.streams.get(m.id) : null;
+ if (!h) return;
+ if (m.t === 'res' && h.onRes) h.onRes(m);
+ else if (m.t === 'abort') { this.release(m.id); if (h.onAbort) h.onAbort(m); }
+ else if (m.t === 'pause' && h.onPause) h.onPause();
+ else if (m.t === 'resume' && h.onResume) h.onResume();
+ }
+
+ shutdown(code, why) {
+ if (this.closed) return;
+ this.closed = true;
+ for (const [, h] of this.streams) { try { if (h.onAbort) h.onAbort({ code: 'tunnel_closed', message: why }); } catch { /* the visitor is gone */ } }
+ this.streams.clear();
+ try { this.ws.close(code, String(why || '').slice(0, 120)); } catch { /* already closing */ }
+ setTimeout(() => { try { this.ws.terminate(); } catch { /* gone */ } }, 2000).unref();
+ }
+}
+
+function createTunnels({ shares, config, log = console }) {
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 * 1024, perMessageDeflate: false });
+ const live = new Map(); // share id → Tunnel
+ const stats = { connects: 0, refused: 0, replaced: 0 };
+
+ const pinger = setInterval(() => {
+ for (const t of live.values()) {
+ if (!t.alive) { t.shutdown(4008, 'no answer to ping'); continue; }
+ t.alive = false;
+ try { t.ws.ping(); } catch { /* closing */ }
+ }
+ }, config.tunnel.pingMs);
+ pinger.unref();
+
+ function refuse(ws, code, message) {
+ stats.refused++;
+ try { ws.send(JSON.stringify({ t: 'error', code, message })); } catch { /* gone */ }
+ // 4002: the key is wrong, 4003: the share is closed (the client stops for both); 4000: anything else.
+ try { ws.close(code === 'key_invalid' ? 4002 : code === 'share_revoked' ? 4003 : 4000, message.slice(0, 120)); } catch { /* gone */ }
+ }
+
+ function accept(ws) {
+ let tunnel = null;
+ const hello = setTimeout(() => refuse(ws, 'hello_timeout', 'Say hello with your connect key first.'), config.tunnel.helloMs);
+ ws.on('pong', () => { if (tunnel) tunnel.alive = true; });
+ ws.on('error', () => {});
+ ws.on('close', () => {
+ clearTimeout(hello);
+ if (tunnel) {
+ tunnel.shutdown(1000, 'closed');
+ if (live.get(tunnel.share.id) === tunnel) live.delete(tunnel.share.id);
+ }
+ });
+ ws.on('message', async (message, isBinary) => {
+ if (tunnel) { tunnel.alive = true; return tunnel.onMessage(message, isBinary); }
+ if (isBinary) return refuse(ws, 'hello_expected', 'The first message is the hello.');
+ let m;
+ try { m = JSON.parse(String(message)); } catch { return refuse(ws, 'hello_expected', 'The first message is the hello.'); }
+ if (!m || m.t !== 'hello') return refuse(ws, 'hello_expected', 'The first message is the hello.');
+ clearTimeout(hello);
+ if (m.v !== frames.VERSION) return refuse(ws, 'version', `This server speaks ${frames.PROTOCOL}. Download the client again from ${config.baseUrl}/ov-connect.js`);
+ let share;
+ try { share = await shares.byKey(m.key); } catch (err) { log.warn('[OpenVibe.Connect] key lookup failed:', err.message); return refuse(ws, 'unavailable', 'Try again in a moment.'); }
+ if (!share) return refuse(ws, 'key_invalid', 'That connect key is not one we know. Make a new one on your share page.');
+ if (share.status !== 'active') return refuse(ws, 'share_revoked', 'That share is closed.');
+ const t = new Tunnel(ws, share, { log, maxStreams: config.shares.maxStreams });
+ t.clientInfo = { client: String(m.client || '').slice(0, 60), port: Number.isInteger(m.port) ? m.port : null };
+ const old = live.get(share.id);
+ if (old) { stats.replaced++; old.shutdown(4001, 'replaced by a newer connection'); }
+ live.set(share.id, t);
+ tunnel = t;
+ stats.connects++;
+ shares.touchConnected(share.id).catch(() => {});
+ t.send({ t: 'ready', v: frames.VERSION, share: { id: share.id, host: share.host, url: share.url, mode: share.mode } });
+ return undefined;
+ });
+ }
+
+ function handleUpgrade(req, socket, head) {
+ wss.handleUpgrade(req, socket, head, (ws) => accept(ws));
+ }
+
+ function close(shareId, code = 4003, why = 'closed') {
+ const t = live.get(shareId);
+ if (!t) return false;
+ live.delete(shareId);
+ t.shutdown(code, why);
+ return true;
+ }
+
+ function closeAll() {
+ clearInterval(pinger);
+ for (const id of [...live.keys()]) close(id, 1001, 'server restarting');
+ wss.close();
+ }
+
+ function status() {
+ return { connected: live.size, streams: [...live.values()].reduce((n, t) => n + t.streams.size, 0), ...stats };
+ }
+
+ return { handleUpgrade, get: (id) => live.get(id) || null, close, closeAll, status, isConnected: (id) => live.has(id) };
+}
+
+module.exports = { createTunnels, Tunnel, HIGH_WATER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+/**
+ * /api/v1: OpenVibe.Connect's JSON API, for a person (the connect session cookie, or a Network user token as a
+ * Bearer). A session write must come from connect.openvibe.website itself (403 connect.cross_site).
+ *
+ * GET /shares your shares (website.share@1 shapes, plus connected)
+ * POST /shares { label, port, mode?, named? } → 201 { share, key } the key is in this answer only
+ * GET /shares/:id one of your shares
+ * PATCH /shares/:id { mode?, named?, port?, note? }
+ * DELETE /shares/:id close it (the host answers 410 from then on)
+ * POST /shares/:id/key a new key → { share, key }; the connected computer is disconnected
+ * GET /shares/:id/visits who opened it in the last 7 days
+ *
+ * Errors are application/problem+json with a `code`.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { ShareError } = require('../shares');
+
+function createApi(ctx) {
+ const { shares, tunnels, principal, proxy } = ctx;
+ const r = asyncRouter();
+ r.use(express.json({ limit: '16kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+ r.use(principal.requirePerson);
+
+ const fail = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const owner = (req) => ({ subject: req.principal.subject, username: req.principal.username });
+ const shape = (s) => ({ ...s, connected: tunnels.isConnected(s.id) });
+ const guard = (fn) => async (req, res) => {
+ try { return await fn(req, res); } catch (err) {
+ if (err instanceof ShareError) return fail(res, req, err.status, err.code, err.message);
+ throw err;
+ }
+ };
+
+ r.get('/shares', guard(async (req, res) => res.json({ shares: (await shares.list(req.principal.subject)).map(shape) })));
+
+ r.post('/shares', guard(async (req, res) => {
+ if (!req.principal.username) return fail(res, req, 403, 'identity.no_username', 'Your account needs a username before it can share.');
+ const b = req.body || {};
+ const { share, key } = await shares.create(owner(req), { label: b.label, port: b.port, mode: b.mode, named: b.named, note: b.note });
+ return res.status(201).json({ share: shape(share), key });
+ }));
+
+ async function mine(req, res) {
+ const share = await shares.get(req.params.id);
+ if (!share || share.owner_subject !== req.principal.subject) { fail(res, req, 404, 'share.not_found', 'No such share.'); return null; }
+ return share;
+ }
+
+ r.get('/shares/:id', guard(async (req, res) => {
+ const share = await mine(req, res);
+ return share ? res.json({ share: shape(share) }) : undefined;
+ }));
+
+ r.patch('/shares/:id', guard(async (req, res) => {
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const b = req.body || {};
+ const updated = await shares.setPolicy(owner(req), share.id, { mode: b.mode, named: b.named, port: b.port, note: b.note });
+ proxy.forget(updated.host);
+ return res.json({ share: shape(updated) });
+ }));
+
+ r.delete('/shares/:id', guard(async (req, res) => {
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const closed = await shares.revoke(owner(req), share.id);
+ tunnels.close(share.id, 4003, 'share closed');
+ proxy.forget(closed.host);
+ return res.json({ share: shape(closed) });
+ }));
+
+ r.post('/shares/:id/key', guard(async (req, res) => {
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const { share: updated, key } = await shares.rotateKey(owner(req), share.id);
+ tunnels.close(share.id, 4003, 'the key was replaced');
+ return res.json({ share: shape(updated), key });
+ }));
+
+ r.get('/shares/:id/visits', guard(async (req, res) => {
+ const share = await mine(req, res);
+ return share ? res.json({ visits: await shares.visits(share.id) }) : undefined;
+ }));
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * Connect's pages on connect.openvibe.website. They work without JavaScript: forms are plain POSTs.
+ *
+ * / the front door; signed in, your shares and the form to make one
+ * POST /shares make a share → its page, with the key shown once
+ * /shares/:id one share: connected or not, who may open it, who did, the command
+ * POST /shares/:id/policy who can open it, the port
+ * POST /shares/:id/key a new key (shown once); the connected computer is disconnected
+ * POST /shares/:id/close close it: the address answers "closed" from then on
+ * /grant?share=&next= the gate's sign-in step (server/edge/gate.js): signed in and allowed → back to the
+ * share host with a one-time code
+ * /robots.txt, /llms.txt
+ */
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { send } = require('../render/layout');
+const views = require('../views');
+const { ShareError } = require('../shares');
+
+function createPageRoutes(ctx) {
+ const { config, shares, tunnels, gate, proxy } = ctx;
+ const r = asyncRouter();
+ r.use(express.urlencoded({ extended: false, limit: '16kb' }));
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
+ const owner = (req) => ({ subject: req.viewer.subject, username: req.viewer.username });
+ const loginTo = (path) => `/auth/login?next=${encodeURIComponent(path)}`;
+ const guard = (req, res) => {
+ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return false; }
+ if (!sameOrigin(req, config.baseUrl)) {
+ page(req, res, { title: 'Not from this site', body: views.message({ title: 'That came from somewhere else', text: 'Changes to your shares must be made on connect.openvibe.website itself.', link: { href: '/', label: 'Your shares' } }) }, 403);
+ return false;
+ }
+ return true;
+ };
+ const connectedIds = (list) => new Set(list.filter((s) => tunnels.isConnected(s.id)).map((s) => s.id));
+
+ async function homeStats() {
+ const t = tunnels.status();
+ const open = await ctx.s.db.value("SELECT count(*)::int FROM shares WHERE status = 'active'");
+ return { connected: t.connected, shares: open };
+ }
+
+ r.get('/', async (req, res) => {
+ const list = signedIn(req) ? await shares.list(req.viewer.subject) : [];
+ return page(req, res, {
+ title: null,
+ index: true,
+ description: 'OpenVibe Connect: share one port on your computer at its own HTTPS address, behind OpenVibe sign-in. Only the people you pick can open it. One file, Node 22, no router settings.',
+ body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl }),
+ });
+ });
+
+ r.post('/shares', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const input = { label: req.body.label, port: req.body.port, mode: req.body.mode, named: req.body.named };
+ try {
+ const { share, key } = await shares.create(owner(req), input);
+ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: false, key, baseUrl: config.baseUrl, notice: 'Your share is made. Run the command below on your computer.' }) }, 201);
+ } catch (err) {
+ if (!(err instanceof ShareError)) throw err;
+ const list = await shares.list(req.viewer.subject);
+ const form = { values: input, error: err.message, username: req.viewer.username };
+ return page(req, res, { title: 'Make a share', body: views.home({ viewer: req.viewer, shares: list, connectedIds: connectedIds(list), stats: await homeStats(), baseUrl: config.baseUrl, form }) }, err.status || 422);
+ }
+ });
+
+ async function mine(req, res) {
+ if (!signedIn(req)) { res.redirect(303, loginTo(req.originalUrl)); return null; }
+ const share = await shares.get(req.params.id);
+ if (!share || share.owner_subject !== req.viewer.subject) {
+ page(req, res, { title: 'Not found', body: views.notFound() }, 404);
+ return null;
+ }
+ return share;
+ }
+
+ async function showShare(req, res, share, extra = {}, status = 200) {
+ const tunnel = tunnels.get(share.id);
+ const visits = await shares.visits(share.id);
+ return page(req, res, { title: share.host, body: views.sharePage({ share, connected: Boolean(tunnel), tunnel, visits, baseUrl: config.baseUrl, ...extra }) }, status);
+ }
+
+ r.get('/shares/:id', async (req, res) => {
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const notice = req.query.saved ? 'Saved.' : null;
+ return showShare(req, res, share, { notice });
+ });
+
+ r.post('/shares/:id/policy', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ try {
+ const updated = await shares.setPolicy(owner(req), share.id, { mode: req.body.mode, named: req.body.named, port: req.body.port === undefined || req.body.port === '' ? undefined : Number(req.body.port) });
+ proxy.forget(updated.host);
+ return res.redirect(303, `/shares/${encodeURIComponent(share.id)}?saved=1`);
+ } catch (err) {
+ if (!(err instanceof ShareError)) throw err;
+ return showShare(req, res, share, { error: err.message }, err.status || 422);
+ }
+ });
+
+ r.post('/shares/:id/key', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ try {
+ const { share: updated, key } = await shares.rotateKey(owner(req), share.id);
+ tunnels.close(share.id, 4003, 'the key was replaced');
+ return showShare(req, res, updated, { key, notice: 'A new key is made. The old one no longer works.' });
+ } catch (err) {
+ if (!(err instanceof ShareError)) throw err;
+ return showShare(req, res, share, { error: err.message }, err.status || 422);
+ }
+ });
+
+ r.post('/shares/:id/close', async (req, res) => {
+ if (!guard(req, res)) return undefined;
+ const share = await mine(req, res);
+ if (!share) return undefined;
+ const closed = await shares.revoke(owner(req), share.id);
+ tunnels.close(share.id, 4003, 'share closed');
+ proxy.forget(closed.host);
+ return showShare(req, res, closed, { notice: 'Closed. The address answers "closed" from now on.' });
+ });
+
+ // The gate's sign-in step. GET only; nothing changes here but a one-time code in memory.
+ r.get('/grant', async (req, res) => {
+ res.set('Cache-Control', 'no-store');
+ const id = typeof req.query.share === 'string' ? req.query.share.slice(0, 64) : '';
+ const next = gate.safeNext(typeof req.query.next === 'string' ? req.query.next : '/');
+ const share = id ? await shares.get(id) : null;
+ if (!share || share.status !== 'active') {
+ return page(req, res, { title: 'No such share', body: views.message({ title: 'No such share', text: share ? `${share.host} is closed.` : 'That share does not exist.', link: { href: '/', label: 'OpenVibe Connect' } }) }, share ? 410 : 404);
+ }
+ if (!signedIn(req)) return res.redirect(303, loginTo(req.originalUrl));
+ if (!shares.allows(share, req.viewer)) {
+ proxy.denied(share, req.viewer);
+ return page(req, res, { title: 'Not for you', body: views.grantDenied({ share, viewer: req.viewer }) }, 403);
+ }
+ const code = gate.mintCode({ shareId: share.id, subject: req.viewer.subject, username: req.viewer.username });
+ res.set('Referrer-Policy', 'no-referrer');
+ return res.redirect(303, `${share.url}/__ovc/callback?code=${encodeURIComponent(code)}&next=${encodeURIComponent(next)}`);
+ });
+
+ r.get('/robots.txt', (req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(`User-agent: *\nAllow: /$\nDisallow: /shares\nDisallow: /grant\nDisallow: /auth\nDisallow: /api\n`);
+ });
+ r.get('/llms.txt', (req, res) => {
+ res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send([
+ '# OpenVibe Connect',
+ '',
+ '> Share one port on your computer at https://<name>--<username>.openvibe.website, behind OpenVibe sign-in.',
+ '',
+ '- Client: one file, Node 22+: curl -fsSLO https://connect.openvibe.website/ov-connect.js; node ov-connect.js --key ovc_… --port 8080',
+ '- Who can open a share: only the owner, the owner and named OpenVibe usernames, or anyone signed in to OpenVibe. No public shares yet.',
+ '- The app behind a share receives X-OpenVibe-User and X-OpenVibe-Subject; Host is localhost:<port>.',
+ '- API (a person\'s Network token as a Bearer): GET/POST /api/v1/shares, GET/PATCH/DELETE /api/v1/shares/{id}, POST /api/v1/shares/{id}/key',
+ '- Source: https://github.com/OpenVibers/OpenVibe.Connect (AGPL-3.0)',
+ '',
+ ].join('\n'));
+ });
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'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 (the connect session).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience connect.openvibe.website. No Connect 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, caption 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}$/;
+
+// Connect's routes are a person acting for themself. 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.Connect's capabilities`);
+ return function guard(req, res, next) {
+ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, 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, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
+ };
+ }
+
+ /** A person (session cookie or Bearer); a write made with the session must come from this site. */
+ function requirePerson(req, res, next) {
+ if (req.principal.kind === 'user') {
+ if (req.principal.viaSession && req.method !== 'GET' && req.method !== 'HEAD' && !sameOrigin(req, config.baseUrl)) {
+ return http.sendProblem(res, 403, 'connect.cross_site', { detail: 'A signed-in request that changes something must come from connect.openvibe.website itself.', ctx: req.ov });
+ }
+ return next();
+ }
+ if (req.principal.kind === 'app') return http.sendProblem(res, 403, 'connect.person_required', { detail: 'This route is for a person, not an app or a service.', ctx: req.ov });
+ return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at connect.openvibe.website, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ }
+
+ return { middleware, resolve, requireCapability: require_, requirePerson };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
+ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * Account export and deletion → Connect (ADR-033; openvibe-sdk/account-data). A person owns their shares and their own
+ * visits to other people's shares (one row per share and day: a count, never a path or an address).
+ *
+ * Erasing deletes both: the shares go (and with them their visit rows, ON DELETE CASCADE), so their addresses answer
+ * "no share here" and a connected computer can no longer serve them. The export leaves out key_hash: it is the
+ * hash of a random key, nothing a person can use, and it is not theirs to carry elsewhere.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'share_visits', subject: 'subject', value: (usr) => usr, file: 'visits.json', erase: 'delete' },
+ {
+ table: 'shares', subject: 'owner_subject', value: (usr) => usr, file: 'shares.json', erase: 'delete',
+ columns: ['id', 'owner_username', 'label', 'host', 'local_port', 'mode', 'named', 'status', 'policy_version', 'key_prefix', 'note',
+ 'created_at', 'updated_at', 'revoked_at', 'last_connected_at', 'last_visit_at', 'requests', 'bytes_in', 'bytes_out'],
+ },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'connect',
+ tables: TABLES,
+ note: 'Your shares on OpenVibe Connect and your visits to other people\'s shares. Connect never stores what passed through a share.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * OpenVibe.Connect: process entry. `node server/index.js`
+ * Listens on PORT (5240) behind nginx (deploy/): connect.openvibe.website and every <label>--<user>.openvibe.website.
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the tunnels close (their computers reconnect to
+ * the next process), the counters are written, the JWKS refresher stops, the store closes and the Events
+ * subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Connect', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.tunnels.closeAll(),
+ () => ctx.proxy.stop(),
+ () => ctx.gate.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.Connect] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}, shares at *--*.${config.shares.domain} (db ${ctx.s.db.store})`);
+ ctx.keys.ensure().catch(() => {});
+ });
+ server.keepAliveTimeout = 65_000;
+ // A share's request may stream for as long as its app streams (server-sent events): no overall request timeout.
+ server.requestTimeout = 0;
+ server.headersTimeout = 60_000;
+ server.on('upgrade', ctx.onUpgrade);
+ 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();
+
+ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
+ // delivery secret and the client secret.
+ 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.Connect] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
+ * the OAuth client 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, tunnels = null }) {
+ return createReadiness({
+ service: 'connect',
+ 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') },
+ // How many computers are connected right now (never required: zero is a fine state).
+ { name: 'tunnels', required: false, check: () => (tunnels ? { ok: true, detail: tunnels.status() } : { skipped: 'no tunnel server' }) },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c, i) => (typeof headers[i] === 'string' && headers[i] ? html`<td data-label="${headers[i]}">${c}</td>` : html`<td>${c}</td>`))}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * Connect is the "your machine" half of openvibe.website, so it wears that room's accent (openvibe-shared/rooms
+ * `website`, #8EA0FF dark / #4B53C2 light); the navigation's name is `Connect`.
+ */
+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 { esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe Connect';
+const MENU_NAME = 'Connect';
+const TAGLINE = 'Your computer, on the web, privately.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Connect', href: '/', icon: 'fa-plug' },
+ { label: 'Sites', href: 'https://openvibe.space', icon: 'fa-globe' },
+];
+const API_BASE = 'https://connect.openvibe.website';
+
+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 (trusted HTML), viewer, config, path, active, robots, canonical, jsonLd
+ */
+function layout(o) {
+ const config = o.config || { baseUrl: API_BASE };
+ const signedIn = o.viewer && o.viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const active = o.active || pathNow;
+ const canonical = o.canonical === undefined ? `${String(config.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}` : o.canonical;
+ const navbar = {
+ service: 'connect',
+ // connect.openvibe.website reads as OpenVibe.Website by its host; the brand says Connect.
+ brand: { tld: 'website', tldLabel: 'Connect' },
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ ...l, active: l.href === active || (l.href !== '/' && pathNow.startsWith(`${l.href}/`)) })),
+ appBar: [
+ { label: 'Shares', href: '/', icon: 'fa-plug' },
+ { label: 'New', href: '/#new', icon: 'fa-plus' },
+ ],
+ silentLogin: `${String(config.baseUrl).replace(/\/+$/, '')}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ history: { type: 'page', title: o.title || SITE_NAME },
+ };
+ const footer = { service: 'connect', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ 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} Share one port on your computer at its own address, behind OpenVibe sign-in.`,
+ canonical,
+ robots: o.robots || (o.index ? 'index, follow' : 'noindex, follow'),
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar,
+ footer,
+ head: `${appIcon.headTags({ site: 'website' })}
+<link rel="stylesheet" href="${esc(ovServe.url('kit.css'))}">
+<link rel="stylesheet" href="${esc(ovServe.url('room.css'))}">
+<link rel="stylesheet" href="${esc(ovServe.url('showcase.css'))}">
+<link rel="stylesheet" href="${asset('css/connect.css')}">
+<script src="${esc(ovServe.url('kit-web.js'))}" defer></script>
+<script src="${esc(ovServe.url('motion.js'))}" defer></script>
+<meta name="ov-boost" content="connect@${esc(RELEASE)}">`,
+ 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">${signedIn
+ ? `Signed in as <strong>${esc(o.viewer.displayName || o.viewer.username || 'you')}</strong>`
+ : '<a href="/auth/login">Sign in with OpenVibe</a>'}</div></noscript>
+<main id="main" class="cn ov-room" style="${rooms.style('website')}">${o.body}</main>
+<script>window.__OV_PAGE = ${JSON.stringify({ navbar, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () { try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ } });</script>`,
+ });
+}
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ public: true, maxAge: 60 })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(layout(o));
+}
+
+module.exports = { layout, send, renderPage: layout, asset, assetVersion, setRelease, SITE_NAME, MENU_NAME, TAGLINE, NETWORK_URL, NAV, API_BASE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,250 @@
@@ -0,0 +1,250 @@
+'use strict';
+
+/**
+ * Shares: one port on a person's machine, served at https://<label>--<user>.<domain> behind OpenVibe sign-in.
+ *
+ * create(owner, { label, port, mode, named, note }) → { share, key } the key is returned once, never stored
+ * list(ownerSubject), get(id), byHost(host), byKey(key)
+ * setPolicy(owner, id, { mode, named }) bumps policy_version: every visitor's cookie goes stale
+ * rotateKey(owner, id) → key the old key stops working; a connected tunnel is closed
+ * revoke(owner, id) the host answers 410 from now on
+ * hostFor(label, username), parseHost(host) the naming rule (pure)
+ *
+ * Names: the label and the person's handle are DNS-safe (a–z, 0–9, single hyphens, no hyphen at either end), joined
+ * by "--". OpenVibe.Space's site names can never contain "--" (its NAME_RE), so a share never takes a Space site's
+ * name and Space never takes a share's. The whole label is at most 63 characters.
+ *
+ * Modes (website.policy@1's subset that v0 serves): owner (only the person who made it), named (the owner and the
+ * OpenVibe usernames listed) and signin (anyone signed in to OpenVibe). A public share waits for the abuse screen
+ * and the Public Suffix List entry (ADR-057, CON-9 and CON-21), so it is refused here.
+ */
+const crypto = require('crypto');
+
+const LABEL_RE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,39}$/;
+const MODES = ['owner', 'named', 'signin'];
+const RESERVED_LABELS = new Set(['www', 'api', 'app', 'admin', 'connect', 'hooks', 'edge', 'share', 'status', 'mail', 'space', 'auth', 'login', 'openvibe']);
+const USERNAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
+
+class ShareError extends Error {
+ constructor(status, code, message) { super(message); this.status = status; this.code = code; }
+}
+
+/** A person's OpenVibe username as one DNS-safe handle ("Japanese_Old.Guy" → "japanese-old-guy"). */
+function handleOf(username) {
+ return String(username || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 30).replace(/-+$/, '');
+}
+
+function hostFor(label, username, domain) {
+ const handle = handleOf(username);
+ if (!handle) return null;
+ const first = `${label}--${handle}`;
+ if (first.length > 63) return null;
+ return `${first}.${domain}`;
+}
+
+/** The share label in a host, or null: "agents--alex.openvibe.website" → { label: 'agents', handle: 'alex' }. */
+function parseHost(host, domain) {
+ const h = String(host || '').toLowerCase().replace(/:\d+$/, '');
+ const suffix = `.${domain}`;
+ if (!h.endsWith(suffix)) return null;
+ const first = h.slice(0, -suffix.length);
+ if (!first || first.length > 63 || first.includes('.')) return null;
+ const at = first.indexOf('--');
+ if (at <= 0) return null;
+ const label = first.slice(0, at);
+ const handle = first.slice(at + 2);
+ if (!LABEL_RE.test(label) || !LABEL_RE.test(handle)) return null;
+ return { host: h, label, handle };
+}
+
+const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
+function newKey() { return `ovc_${crypto.randomBytes(32).toString('base64url')}`; }
+
+function normalizeNamed(list) {
+ const raw = Array.isArray(list) ? list : String(list || '').split(/[\s,]+/);
+ const out = [];
+ for (const item of raw) {
+ const u = String(item || '').trim().replace(/^@/, '');
+ if (!u) continue;
+ if (!USERNAME_RE.test(u)) throw new ShareError(422, 'share.invalid_username', `"${u.slice(0, 64)}" is not an OpenVibe username`);
+ const lower = u.toLowerCase();
+ if (!out.includes(lower)) out.push(lower);
+ }
+ if (out.length > 50) throw new ShareError(422, 'share.too_many_people', 'A share can name up to 50 people.');
+ return out;
+}
+
+function createShares(s, { config }) {
+ const { db } = s;
+ const domain = config.shares.domain;
+ const scheme = config.shares.scheme;
+
+ const urlOf = (row) => `${scheme}://${row.host}`;
+ function view(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ owner_subject: row.owner_subject,
+ owner_username: row.owner_username,
+ label: row.label,
+ host: row.host,
+ url: urlOf(row),
+ local_port: Number(row.local_port),
+ mode: row.mode,
+ named: Array.isArray(row.named) ? row.named : [],
+ status: row.status,
+ policy_version: Number(row.policy_version),
+ key_prefix: row.key_prefix,
+ note: row.note || null,
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ revoked_at: iso(row.revoked_at),
+ last_connected_at: iso(row.last_connected_at),
+ last_visit_at: iso(row.last_visit_at),
+ requests: Number(row.requests || 0),
+ bytes_in: Number(row.bytes_in || 0),
+ bytes_out: Number(row.bytes_out || 0),
+ };
+ }
+
+ async function list(ownerSubject) {
+ const rows = await db.many("SELECT * FROM shares WHERE owner_subject = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 100", [ownerSubject]);
+ return rows.map(view);
+ }
+ async function get(id) { return view(await db.maybe('SELECT * FROM shares WHERE id = $1', [String(id || '')])); }
+ async function byHost(host) {
+ return view(await db.maybe("SELECT * FROM shares WHERE host = $1 ORDER BY status = 'active' DESC, created_at DESC LIMIT 1", [String(host || '').toLowerCase()]));
+ }
+ async function byKey(key) {
+ if (typeof key !== 'string' || !/^ovc_[A-Za-z0-9_-]{43}$/.test(key)) return null;
+ return view(await db.maybe('SELECT * FROM shares WHERE key_hash = $1', [sha256(key)]));
+ }
+
+ async function owned(owner, id) {
+ const share = await get(id);
+ if (!share || share.owner_subject !== owner.subject) throw new ShareError(404, 'share.not_found', 'No such share.');
+ return share;
+ }
+
+ async function create(owner, input = {}) {
+ if (!owner || !owner.subject || !owner.username) throw new ShareError(401, 'token.required', 'Sign in to make a share.');
+ const label = String(input.label || '').trim().toLowerCase();
+ if (!LABEL_RE.test(label)) throw new ShareError(422, 'share.invalid_label', 'A name is 1–40 characters: a–z, 0–9 and single hyphens, not at either end.');
+ if (RESERVED_LABELS.has(label)) throw new ShareError(422, 'share.reserved_label', `"${label}" is kept for OpenVibe itself. Pick another name.`);
+ const port = Number(input.port);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');
+ const mode = input.mode == null || input.mode === '' ? 'owner' : String(input.mode);
+ if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in. For now a share asks every visitor to sign in with OpenVibe.');
+ if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);
+ const named = normalizeNamed(input.named);
+ const host = hostFor(label, owner.username, domain);
+ if (!host) throw new ShareError(422, 'share.name_too_long', 'That name and your username make an address longer than 63 characters. Pick a shorter name.');
+ const note = input.note ? String(input.note).slice(0, 200) : null;
+
+ const live = await db.one("SELECT count(*)::int AS n FROM shares WHERE owner_subject = $1 AND status = 'active'", [owner.subject]);
+ if (live.n >= config.shares.perPerson) throw new ShareError(409, 'share.limit', `You have ${live.n} shares open, the most for now. Close one first.`);
+ // A host someone else used stays theirs (a revoked share holds its address, so nobody else can serve on links
+ // that still point there). The owner may reuse their own.
+ const taken = await db.maybe('SELECT owner_subject, status FROM shares WHERE host = $1 ORDER BY created_at DESC LIMIT 1', [host]);
+ if (taken && (taken.status === 'active' || taken.owner_subject !== owner.subject)) {
+ throw new ShareError(409, 'share.taken', taken.owner_subject === owner.subject ? 'You already have a share with that name.' : 'That address is taken. Pick another name.');
+ }
+
+ const key = newKey();
+ const id = s.newId('shr');
+ try {
+ await db.exec(
+ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, mode, named, key_prefix, key_hash, note)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10, $11)`,
+ [id, owner.subject, String(owner.username).slice(0, 64), label, host, port, mode, JSON.stringify(named), key.slice(0, 10), sha256(key), note],
+ );
+ } catch (err) {
+ if (err && err.code === '23505') throw new ShareError(409, 'share.taken', 'That address is taken. Pick another name.');
+ throw err;
+ }
+ return { share: await get(id), key };
+ }
+
+ async function setPolicy(owner, id, input = {}) {
+ const share = await owned(owner, id);
+ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
+ const mode = input.mode == null ? share.mode : String(input.mode);
+ if (mode === 'public') throw new ShareError(422, 'share.public_not_yet', 'Public shares open once the abuse check is in.');
+ if (!MODES.includes(mode)) throw new ShareError(422, 'share.invalid_mode', `Who can open it: ${MODES.join(', ')}.`);
+ const named = input.named === undefined ? share.named : normalizeNamed(input.named);
+ const port = input.port === undefined ? share.local_port : Number(input.port);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new ShareError(422, 'share.invalid_port', 'The port is a number from 1 to 65535.');
+ const note = input.note === undefined ? share.note : (input.note ? String(input.note).slice(0, 200) : null);
+ const policyChanged = mode !== share.mode || JSON.stringify(named) !== JSON.stringify(share.named);
+ await db.exec(
+ `UPDATE shares SET mode = $2, named = $3::jsonb, local_port = $4, note = $5, updated_at = now(),
+ policy_version = policy_version + $6 WHERE id = $1`,
+ [share.id, mode, JSON.stringify(named), port, note, policyChanged ? 1 : 0],
+ );
+ return get(share.id);
+ }
+
+ async function rotateKey(owner, id) {
+ const share = await owned(owner, id);
+ if (share.status !== 'active') throw new ShareError(410, 'share.revoked', 'That share is closed.');
+ const key = newKey();
+ await db.exec('UPDATE shares SET key_prefix = $2, key_hash = $3, updated_at = now() WHERE id = $1', [share.id, key.slice(0, 10), sha256(key)]);
+ return { share: await get(share.id), key };
+ }
+
+ async function revoke(owner, id) {
+ const share = await owned(owner, id);
+ if (share.status === 'revoked') return share;
+ await db.exec("UPDATE shares SET status = 'revoked', revoked_at = now(), updated_at = now(), policy_version = policy_version + 1 WHERE id = $1", [share.id]);
+ return get(share.id);
+ }
+
+ async function touchConnected(id) { await db.exec('UPDATE shares SET last_connected_at = now() WHERE id = $1', [id]); }
+
+ /** Flush counters gathered in memory: { [shareId]: { requests, bytesIn, bytesOut, visits: { [subject]: { username, requests, denied } } } } */
+ async function flushCounters(counters, day) {
+ for (const [id, c] of Object.entries(counters)) {
+ await db.exec(
+ `UPDATE shares SET requests = requests + $2, bytes_in = bytes_in + $3, bytes_out = bytes_out + $4,
+ last_visit_at = CASE WHEN $2 > 0 THEN now() ELSE last_visit_at END WHERE id = $1`,
+ [id, c.requests, c.bytesIn, c.bytesOut],
+ );
+ for (const [subject, v] of Object.entries(c.visits || {})) {
+ await db.exec(
+ `INSERT INTO share_visits (share_id, day, subject, username, requests, denied, last_at) VALUES ($1, $2, $3, $4, $5, $6, now())
+ ON CONFLICT (share_id, day, subject) DO UPDATE SET requests = share_visits.requests + EXCLUDED.requests,
+ denied = share_visits.denied + EXCLUDED.denied, username = COALESCE(EXCLUDED.username, share_visits.username), last_at = now()`,
+ [id, day, subject, v.username || null, v.requests || 0, v.denied || 0],
+ );
+ }
+ }
+ }
+
+ async function visits(id, { days = 7 } = {}) {
+ const rows = await db.many(
+ `SELECT subject, max(username) AS username, sum(requests)::int AS requests, sum(denied)::int AS denied, max(last_at) AS last_at
+ FROM share_visits WHERE share_id = $1 AND day >= (current_date - $2::int) GROUP BY subject ORDER BY max(last_at) DESC LIMIT 50`,
+ [id, days],
+ );
+ return rows.map((r) => ({ subject: r.subject, username: r.username, requests: r.requests, denied: r.denied, last_at: iso(r.last_at) }));
+ }
+
+ /** Is this person let in by the share's policy? */
+ function allows(share, viewer) {
+ if (!share || share.status !== 'active' || !viewer || !viewer.subject) return false;
+ if (viewer.subject === share.owner_subject) return true;
+ if (share.mode === 'signin') return true;
+ if (share.mode === 'named') return Boolean(viewer.username) && share.named.includes(String(viewer.username).toLowerCase());
+ return false;
+ }
+
+ return { create, list, get, byHost, byKey, setPolicy, rotateKey, revoke, touchConnected, flushCounters, visits, allows, view, domain };
+}
+
+function iso(v) {
+ if (!v) return null;
+ const d = v instanceof Date ? v : new Date(v);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+module.exports = { createShares, ShareError, hostFor, parseHost, handleOf, normalizeNamed, LABEL_RE, MODES, RESERVED_LABELS, sha256 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,243 @@
@@ -0,0 +1,243 @@
+'use strict';
+
+/**
+ * Connect's pages, server-rendered and complete without JavaScript (forms are plain POSTs). The front door is
+ * sc.room (hero → your shares → scenes → developers → questions → the rest of OpenVibe); a share has one page with
+ * its address, whether it is connected, who may open it, who did, and the command. A key is shown once, on the page
+ * that made it.
+ */
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+const { esc } = require('../render/html');
+
+const CLIENT_URL = (base) => `${base}/ov-connect.js`;
+const MODE_TEXT = {
+ owner: 'Only you',
+ named: 'You and the people you name',
+ signin: 'Anyone signed in to OpenVibe',
+};
+
+function fmtBytes(n) {
+ const v = Number(n) || 0;
+ if (v < 1024) return `${v} B`;
+ if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KB`;
+ if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MB`;
+ return `${(v / 1024 ** 3).toFixed(2)} GB`;
+}
+function ago(iso, now = Date.now()) {
+ if (!iso) return 'never';
+ const s = Math.max(0, Math.round((now - Date.parse(iso)) / 1000));
+ if (s < 60) return 'just now';
+ if (s < 3600) return `${Math.round(s / 60)} min ago`;
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
+ return `${Math.round(s / 86400)} days ago`;
+}
+
+/** The hero's picture: a port on a laptop, a line through OpenVibe, an address on a phone with a lock. */
+function motif() {
+ return `<div class="cn-motif" aria-hidden="true">
+<div class="cn-m-box cn-m-pc"><span class="cn-m-k">your computer</span><b>localhost:8080</b><span class="cn-m-dot"></span></div>
+<div class="cn-m-line"><i></i><span>one outgoing connection</span></div>
+<div class="cn-m-box cn-m-phone"><span class="cn-m-k">anywhere</span><b>agents--you.openvibe.website</b><span class="cn-m-lock">${kit.icon('lock')} signed in as you</span></div>
+</div>`;
+}
+
+function terminal(lines) {
+ return `<div class="cn-term" role="img" aria-label="A terminal running ov-connect"><div class="cn-term-bar"><i></i><i></i><i></i></div><pre>${lines.map((l) => (l.startsWith('$ ') ? `<span class="cn-term-cmd">${esc(l)}</span>` : `<span class="cn-term-out">${esc(l)}</span>`)).join('\n')}</pre></div>`;
+}
+
+function phone() {
+ return `<div class="cn-phone" role="img" aria-label="A phone opening a share"><div class="cn-phone-url">${kit.icon('lock')}<span>agents--you.openvibe.website</span></div>
+<div class="cn-phone-body"><b>Your agent portal</b><span>Runs on your desk. Open here, over 5G.</span><span class="cn-phone-pill">Signed in as you</span></div></div>`;
+}
+
+function picker(mode = 'named') {
+ return `<div class="cn-pick" role="img" aria-label="Choosing who can open a share">${Object.entries(MODE_TEXT).map(([k, v]) => `<span class="cn-pick-row${k === mode ? ' is-on' : ''}"><i></i>${esc(v)}</span>`).join('')}
+<span class="cn-pick-names"><b>@sam</b><b>@riley</b></span></div>`;
+}
+
+function shareRow(s, { connected }) {
+ const state = s.status !== 'active' ? ['closed', 'is-closed'] : connected ? ['connected', 'is-on'] : ['not connected', 'is-off'];
+ return `<li class="cn-share"><a href="/shares/${esc(s.id)}">
+<span class="cn-share-dot ${state[1]}" aria-hidden="true"></span>
+<span class="cn-share-main"><b>${esc(s.host)}</b><span>${esc(state[0])} · port ${esc(s.local_port)} · ${esc(MODE_TEXT[s.mode] || s.mode)}</span></span>
+${kit.icon('chevron')}</a></li>`;
+}
+
+function createForm({ values = {}, error = null, username = '' } = {}) {
+ const handle = String(username || 'you').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'you';
+ return `<form class="cn-form" method="post" action="/shares" id="new">
+<h2>Make a share</h2>
+${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
+<div class="cn-fields">
+<label class="cn-field"><span>Name</span><span class="cn-addr"><input name="label" required maxlength="40" pattern="[a-z0-9]([a-z0-9]|-(?=[a-z0-9])){0,39}" value="${esc(values.label || '')}" placeholder="agents" autocapitalize="none" autocomplete="off" spellcheck="false"><span class="cn-addr-tail">--${esc(handle)}.openvibe.website</span></span><small>a–z, 0–9 and hyphens</small></label>
+<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" required inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(values.port || '')}" placeholder="8080"></label>
+</div>
+<fieldset class="cn-modes"><legend>Who can open it</legend>
+${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${(values.mode || 'owner') === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}
+</fieldset>
+<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(values.named || '')}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
+<button class="cn-btn" type="submit">${kit.icon('plus')} Make the share</button>
+<p class="cn-fine">Every visitor signs in with OpenVibe first. Public shares, open to anyone, come once the abuse check is in.</p>
+</form>`;
+}
+
+function yourShares({ viewer, shares, connectedIds, form }) {
+ if (!viewer || viewer.kind !== 'user') return '';
+ const open = shares.filter((s) => s.status === 'active');
+ const closed = shares.filter((s) => s.status !== 'active');
+ const list = open.length
+ ? `<ul class="cn-shares">${open.map((s) => shareRow(s, { connected: connectedIds.has(s.id) })).join('')}</ul>`
+ : '<p class="cn-empty">No shares yet. Make one below: a name, a port, who can open it.</p>';
+ const closedList = closed.length ? `<details class="cn-closed"><summary>Closed shares (${closed.length})</summary><ul class="cn-shares">${closed.map((s) => shareRow(s, { connected: false })).join('')}</ul></details>` : '';
+ return `<section class="cn-yours" aria-labelledby="cn-yours-h"><h2 id="cn-yours-h">Your shares</h2>${list}${closedList}${createForm(form || { username: viewer.username })}</section>`;
+}
+
+function home({ viewer, shares = [], connectedIds = new Set(), stats = {}, baseUrl, form = null }) {
+ const signedIn = viewer && viewer.kind === 'user';
+ const body = sc.room({
+ room: 'website',
+ hero: {
+ title: 'Your computer, on the web, privately',
+ lede: 'Share one port on your computer at its own address, like agents--you.openvibe.website. Only the people you pick can open it, signed in with OpenVibe. No router settings, no open ports, no app to install: one file and Node.',
+ start: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to make a share' },
+ motif: motif(),
+ },
+ proof: [
+ { value: stats.connected, label: stats.connected === 1 ? 'share connected now' : 'shares connected now', live: true },
+ { value: stats.shares, label: 'shares open' },
+ ],
+ scenes: [
+ {
+ title: 'One command on your computer',
+ text: 'Download ov-connect.js once and run it with your share\'s key and port. It opens one outgoing connection to OpenVibe, so it works behind any router, at home or on a phone hotspot, and it reconnects by itself after sleep.',
+ html: terminal([
+ `$ curl -fsSLO ${CLIENT_URL(baseUrl)}`,
+ '$ node ov-connect.js --key ovc_… --port 8080',
+ 'Connected. https://agents--you.openvibe.website → localhost:8080',
+ 'GET / → 200 (14 ms)',
+ ]),
+ action: signedIn ? { href: '#new', label: 'Make a share' } : { href: '/auth/login?next=%2F%23new', label: 'Sign in to start' },
+ },
+ {
+ title: 'Open it anywhere, signed in',
+ text: 'Your agent portal, a dev server, a home dashboard: open its address on your phone over 5G. It is real HTTPS, so the microphone, the camera and WebSockets work. Live streams (server-sent events) stream.',
+ html: phone(),
+ },
+ {
+ title: 'You pick who gets in',
+ text: 'Only you, you and the people you name, or anyone signed in to OpenVibe. Change it any time and everyone already in is asked again. Your app is told who each visitor is, so it can greet them by name.',
+ html: picker(),
+ },
+ ],
+ dev: {
+ title: 'For developers',
+ lede: 'The share page is also an API, with your OpenVibe token. Your app receives X-OpenVibe-User and X-OpenVibe-Subject on every request, and never the gate\'s own cookie.',
+ samples: [
+ { label: 'Make a share', lang: 'bash', code: `curl -X POST ${baseUrl}/api/v1/shares \\\n -H "Authorization: Bearer $OV_TOKEN" -H "Content-Type: application/json" \\\n -d '{"label":"agents","port":8790,"mode":"owner"}'` },
+ { label: 'Your shares', lang: 'bash', code: `curl ${baseUrl}/api/v1/shares -H "Authorization: Bearer $OV_TOKEN"` },
+ { label: 'Run the client', lang: 'bash', code: 'OV_CONNECT_KEY=ovc_… node ov-connect.js 8790' },
+ ],
+ links: [{ label: 'The client\'s source', href: CLIENT_URL(baseUrl) }, { label: 'llms.txt', href: '/llms.txt' }],
+ },
+ faq: {
+ items: [
+ ['What can the server make my computer do?', 'Nothing but answer requests for your share on the one port you gave the client, on localhost. The client never opens a connection anywhere else, and you can read all of it: it is one file.'],
+ ['Who can open my share?', 'People you allow, after they sign in with OpenVibe: only you, you and named people, or anyone signed in. Changing who can open it signs everyone out of that share at once.'],
+ ['Can I make it public?', 'Not yet. A public address can be abused for phishing, so public shares come with the abuse check and openvibe.website\'s entry in the Public Suffix List. Until then every visitor signs in.'],
+ ['What does my app see?', 'The request as the browser sent it, with Host set to localhost:<port>, the share\'s address in X-Forwarded-Host, the visitor\'s address in X-Forwarded-For, and who they are in X-OpenVibe-User and X-OpenVibe-Subject. Cookies your app sets stay on your share\'s address.'],
+ ['What are the limits?', 'Five open shares per person, 64 requests at once per share, 1,200 requests a minute per share, 100 MB per request body, and 10 GB a day across your shares. Nothing is stored: requests pass through.'],
+ ['How is this different from ngrok or Tailscale?', 'It is part of your OpenVibe account: the people you let in are OpenVibe people, no separate login, no install beyond one file. TCP and UDP shares (game servers), custom domains, a private mesh between your machines and durable webhooks are the next steps on the plan.'],
+ ],
+ },
+ network: {
+ service: 'website',
+ title: 'Part of OpenVibe',
+ lede: 'Connect is the "your machine" half of openvibe.website. The other half builds and hosts sites.',
+ items: [
+ { to: 'space', what: 'Build and host a site at <name>.openvibe.website', href: 'https://openvibe.space' },
+ { to: 'network', what: 'The sign-in your visitors use', href: 'https://openvibe.network' },
+ { to: 'codes', what: 'Your agents and machines', href: 'https://openvibe.codes' },
+ ],
+ },
+ });
+ const yours = yourShares({ viewer, shares, connectedIds, form });
+ const at = body.indexOf('<section class="sc-room-scenes"');
+ return `<div class="cn-wrap">${at < 0 ? body + yours : body.slice(0, at) + yours + body.slice(at)}</div>`;
+}
+
+function keyBlock(share, key, baseUrl) {
+ const cmd = `node ov-connect.js --key ${key} --port ${share.local_port}`;
+ return `<section class="cn-key" aria-labelledby="cn-key-h">
+<h2 id="cn-key-h">Your connect key</h2>
+<p>Shown this once. It lets a computer serve this share, so keep it like a password. Lost it? Make a new one below; the old one stops working.</p>
+<div class="cn-copy"><code id="cn-key-v">${esc(key)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-key-v" hidden>${kit.icon('copy')} Copy</button></div>
+<h3>On your computer</h3>
+<ol class="cn-steps">
+<li><span>Download the client once (Node 22 or newer):</span><div class="cn-copy"><code id="cn-dl">curl -fsSLO ${esc(CLIENT_URL(baseUrl))}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-dl" hidden>${kit.icon('copy')} Copy</button></div></li>
+<li><span>Run it next to what you are sharing:</span><div class="cn-copy"><code id="cn-run">${esc(cmd)}</code><button type="button" class="cn-btn cn-btn-quiet" data-ovk-copy="#cn-run" hidden>${kit.icon('copy')} Copy</button></div></li>
+<li><span>Open <a href="${esc(share.url)}">${esc(share.host)}</a> anywhere and sign in.</span></li>
+</ol>
+</section>`;
+}
+
+function sharePage({ share, connected, tunnel = null, visits = [], key = null, baseUrl, notice = null, error = null, now = Date.now() }) {
+ const closed = share.status !== 'active';
+ const state = closed ? 'Closed' : connected ? 'Connected' : 'Not connected';
+ const stateText = closed ? `Closed ${ago(share.revoked_at, now)}. The address answers "closed" and stays yours.`
+ : connected ? `Connected ${ago(tunnel && tunnel.connectedAt ? new Date(tunnel.connectedAt).toISOString() : share.last_connected_at, now)}${tunnel && tunnel.clientInfo && tunnel.clientInfo.port ? `, serving localhost:${tunnel.clientInfo.port}` : ''}.`
+ : share.last_connected_at ? `Last connected ${ago(share.last_connected_at, now)}. Run ov-connect on your computer to bring it back.` : 'Never connected yet. Run the command below on your computer.';
+ const visitRows = visits.length
+ ? `<ul class="cn-visits">${visits.map((v) => `<li><b>${v.username ? `@${esc(v.username)}` : 'someone'}</b><span>${esc(v.requests)} ${v.requests === 1 ? 'request' : 'requests'}${v.denied ? `, ${esc(v.denied)} turned away` : ''} · ${esc(ago(v.last_at, now))}</span></li>`).join('')}</ul>`
+ : '<p class="cn-empty">Nobody has opened it in the last 7 days.</p>';
+ const policy = closed ? '' : `<form class="cn-form cn-policy" method="post" action="/shares/${esc(share.id)}/policy">
+<h2>Who can open it</h2>
+<fieldset class="cn-modes"><legend class="cn-sr">Who can open it</legend>
+${Object.entries(MODE_TEXT).map(([k, v]) => `<label><input type="radio" name="mode" value="${k}"${share.mode === k ? ' checked' : ''}> ${esc(v)}</label>`).join('')}
+</fieldset>
+<label class="cn-field"><span>People (for "the people you name")</span><input name="named" maxlength="2000" value="${esc(share.named.join(', '))}" placeholder="sam, riley" autocapitalize="none" autocomplete="off" spellcheck="false"><small>OpenVibe usernames, separated by commas</small></label>
+<label class="cn-field cn-field-port"><span>Port on your computer</span><input name="port" inputmode="numeric" pattern="[0-9]{1,5}" maxlength="5" value="${esc(share.local_port)}"></label>
+<button class="cn-btn" type="submit">${kit.icon('check')} Save</button>
+<p class="cn-fine">Changing who can open it signs everyone out of this share; the people still allowed are let back in when they next open it.</p>
+</form>`;
+ const danger = closed ? '' : `<section class="cn-danger" aria-labelledby="cn-danger-h"><h2 id="cn-danger-h">Key and closing</h2>
+<form method="post" action="/shares/${esc(share.id)}/key"><button class="cn-btn cn-btn-quiet" type="submit">${kit.icon('key')} Make a new key</button><span>The old key stops working and a connected computer is disconnected.</span></form>
+<form method="post" action="/shares/${esc(share.id)}/close"><button class="cn-btn cn-btn-bad" type="submit">${kit.icon('close')} Close this share</button><span>The address answers "closed" from now on and stays yours.</span></form>
+</section>`;
+ return `<div class="cn-wrap cn-page">
+<p class="cn-back"><a href="/">${kit.icon('back')} Your shares</a></p>
+${notice ? `<p class="cn-notice" role="status">${esc(notice)}</p>` : ''}
+${error ? `<p class="cn-error" role="alert">${esc(error)}</p>` : ''}
+<header class="cn-head"><span class="cn-state ${closed ? 'is-closed' : connected ? 'is-on' : 'is-off'}"><i aria-hidden="true"></i>${esc(state)}</span>
+<h1>${closed ? esc(share.host) : `<a href="${esc(share.url)}">${esc(share.host)}</a>`}</h1>
+<p>${esc(stateText)}</p></header>
+${key ? keyBlock(share, key, baseUrl) : ''}
+<dl class="cn-facts">
+<div><dt>Who can open it</dt><dd>${esc(MODE_TEXT[share.mode] || share.mode)}${share.mode === 'named' && share.named.length ? `: ${share.named.map((n) => `@${esc(n)}`).join(', ')}` : ''}</dd></div>
+<div><dt>Port</dt><dd>localhost:${esc(share.local_port)}</dd></div>
+<div><dt>Requests</dt><dd>${esc(Number(share.requests).toLocaleString('en-US'))}</dd></div>
+<div><dt>Data</dt><dd>${esc(fmtBytes(share.bytes_in))} in · ${esc(fmtBytes(share.bytes_out))} out</dd></div>
+<div><dt>Key</dt><dd><code>${esc(share.key_prefix)}…</code></dd></div>
+<div><dt>Made</dt><dd>${esc(ago(share.created_at, now))}</dd></div>
+</dl>
+${!key && !closed ? `<section class="cn-run"><h2>Connect it</h2><div class="cn-copy"><code id="cn-run">node ov-connect.js --key ovc_… --port ${esc(share.local_port)}</code></div><p class="cn-fine">The key was shown once, when it was made. <a href="${esc(CLIENT_URL(baseUrl))}">Download ov-connect.js</a> (Node 22 or newer).</p></section>` : ''}
+<section aria-labelledby="cn-visits-h"><h2 id="cn-visits-h">Who opened it (7 days)</h2>${visitRows}</section>
+${policy}
+${danger}
+</div>`;
+}
+
+function grantDenied({ share, viewer }) {
+ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><span class="cn-state is-closed"><i aria-hidden="true"></i>Not for you</span>
+<h1>You can't open ${esc(share.host)}</h1>
+<p>${esc(share.owner_username)} has not let ${viewer && viewer.username ? `@${esc(viewer.username)}` : 'this account'} in. Ask them to add your OpenVibe username to the share.</p></header>
+<p><a class="cn-btn cn-btn-quiet" href="/auth/logout?next=${encodeURIComponent(`/grant?share=${share.id}`)}">Use another account</a></p></div>`;
+}
+
+function message({ title, text, link = null }) {
+ return `<div class="cn-wrap cn-page cn-narrow"><header class="cn-head"><h1>${esc(title)}</h1><p>${esc(text)}</p></header>${link ? `<p><a class="cn-btn cn-btn-quiet" href="${esc(link.href)}">${esc(link.label)}</a></p>` : ''}</div>`;
+}
+
+function notFound() { return message({ title: 'Not found', text: 'There is no page here.', link: { href: '/', label: 'OpenVibe Connect' } }); }
+
+module.exports = { home, sharePage, grantDenied, message, notFound, MODE_TEXT, fmtBytes, ago };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * ADR-033: Connect's part of an account export (a person's shares, without the key hash, and their visits) and of a
+ * deletion, applied through /internal/events with a stand-in Network. A deletion erases the person's shares and
+ * visits and leaves other people's alone. 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 } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+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_connect', 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: 'connect', clientSecret: 'connect-secret' });
+ const t = await boot({ env: { CONNECT_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const db = t.ctx.s.db;
+ const shareA = `shr_${ids.ulid()}`;
+ const shareB = `shr_${ids.ulid()}`;
+ await db.exec(
+ `INSERT INTO shares (id, owner_subject, owner_username, label, host, local_port, key_prefix, key_hash)
+ VALUES ($1, $2, 'ann', 'agents', 'agents--ann.openvibe.website', 8790, 'ovc_aaaaaa', 'hash-a'),
+ ($3, $4, 'ben', 'blog', 'blog--ben.openvibe.website', 3000, 'ovc_bbbbbb', 'hash-b')`,
+ [shareA, A, shareB, B],
+ );
+ // A visited B's share; B visited A's.
+ await db.exec(`INSERT INTO share_visits (share_id, day, subject, username, requests) VALUES ($1, current_date, $2, 'ann', 3), ($3, current_date, $4, 'ben', 2)`, [shareB, A, shareA, B]);
+
+ await check('an export carries only A\'s shares (no key hash) and A\'s visits', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_connect');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['shares.json', 'visits.json']);
+ const shares = part.body.files.find((f) => f.name === 'shares.json').content;
+ assert.deepStrictEqual(shares.map((w) => w.id), [shareA]);
+ assert.ok(!JSON.stringify(part.body).includes('hash-a'), 'the key hash stays here');
+ const visits = part.body.files.find((f) => f.name === 'visits.json').content;
+ assert.deepStrictEqual(visits.map((v) => v.share_id), [shareB]);
+ });
+
+ await check('a deletion erases A\'s shares and visits and confirms with counts; B\'s share stays', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares WHERE owner_subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM share_visits WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares 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(confirmation.body.erased.shares >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM shares WHERE owner_subject = $1', [A])), 0);
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.CONNECT_RATE_LIMIT_PER_MIN = process.env.CONNECT_RATE_LIMIT_PER_MIN || '100000';
+process.env.CONNECT_API_RATE_LIMIT_PER_MIN = process.env.CONNECT_API_RATE_LIMIT_PER_MIN || '100000';
+/**
+ * Boots OpenVibe.Connect on a temp database with a mock Network (test/helpers/mocks.js), captures log lines, and
+ * returns small HTTP clients. Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin }) connect.openvibe.website
+ * t.share(host, path, { cookie, headers, method, body }) a share host (Host header set)
+ * t.signIn(user) → cookie header value (connect_at = a Network user token, as after /auth/callback)
+ * t.client({ key, port }) → a started ov-connect client against this server (public/ov-connect.js)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+
+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 env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://connect.openvibe.website', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'connect', OV_OAUTH_CLIENT_SECRET: 'connect-secret', COOKIE_SECURE: 'false',
+ CONNECT_DRILL: '1',
+ CONNECT_EVENTS_URL: network.url,
+ CONNECT_SHARE_SCHEME: 'https',
+ CONNECT_SESSION_SECRET: 'test-session-secret-0123456789abcdef0123',
+ CONNECT_PING_MS: '60000',
+ ...(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.on('upgrade', built.ctx.onUpgrade); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const port = server.address().port;
+ const base = `http://127.0.0.1:${port}`;
+ const sockets = new Set();
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+
+ const signIn = (user) => `connect_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ /** A request to a share host (fetch cannot set Host, so this is http.request). */
+ function share(host, p = '/', o = {}) {
+ return new Promise((resolve, reject) => {
+ const headers = { host, accept: 'text/html', ...(o.headers || {}) };
+ if (o.cookie) headers.cookie = o.cookie;
+ const req = http.request({ host: '127.0.0.1', port, path: p, method: o.method || 'GET', headers }, (res) => {
+ const chunks = [];
+ if (o.onResponse) o.onResponse(res);
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), buffer: buf, json() { return JSON.parse(buf.toString('utf8')); } });
+ });
+ res.on('error', reject);
+ });
+ req.on('error', reject);
+ if (o.body) req.write(o.body);
+ req.end();
+ });
+ }
+
+ const clients = [];
+ function client({ key, port: localPort, quiet = true, server: serverUrl }) {
+ const { createClient } = require('../../public/ov-connect');
+ const lines = [];
+ const c = createClient({ key, port: localPort, server: serverUrl || `ws://127.0.0.1:${port}/tunnel`, quiet, log: (l) => lines.push(l), localHost: '127.0.0.1' });
+ c.lines = lines;
+ c.ready = () => new Promise((resolve) => c.onReady(resolve));
+ clients.push(c);
+ c.start();
+ return c;
+ }
+
+ /** Make a share as `user` through the API → { share, key }. */
+ async function makeShare(user, body) {
+ const r = await get('/api/v1/shares', { as: user, json: body });
+ if (r.status !== 201) throw new Error(`share not made: ${r.status} ${r.text}`);
+ return r.json();
+ }
+
+ /** Walk the gate as `user` for a share: grant → callback → the share's own cookie header value. */
+ async function enter(user, sh, next = '/') {
+ const g = await get(`/grant?share=${encodeURIComponent(sh.id)}&next=${encodeURIComponent(next)}`, { as: user });
+ if (g.status !== 303) return { status: g.status, grant: g };
+ const loc = new URL(g.headers.get('location'));
+ const cb = await share(sh.host, `${loc.pathname}${loc.search}`);
+ const setCookie = [].concat(cb.headers['set-cookie'] || [])[0] || '';
+ return { status: cb.status, location: cb.headers.location, cookie: setCookie.split(';')[0], setCookie };
+ }
+
+ 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, port, network, config, ctx: built.ctx, get, share, signIn, dbDump, client, makeShare, enter,
+ logs: () => captured.join('\n'),
+ async close() {
+ for (const c of clients) c.stop();
+ built.ctx.tunnels.closeAll();
+ built.ctx.gate.stop();
+ await built.ctx.proxy.stop();
+ for (const s of sockets) s.destroy();
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await built.ctx.telemetry.stop().catch(() => {});
+ await testdb.close();
+ await network.close();
+ },
+ };
+ return t;
+}
+
+/** A tiny local app for a share to point at: echoes what it got, streams on /sse, upgrades on /ws (raw echo). */
+function startLocalApp() {
+ return new Promise((resolve) => {
+ const seen = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') });
+ if (req.url === '/sse') {
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
+ let n = 0;
+ const iv = setInterval(() => { n++; res.write(`data: tick ${n}\n\n`); if (n === 3) { clearInterval(iv); res.end(); } }, 20);
+ return;
+ }
+ if (req.url === '/big') {
+ res.writeHead(200, { 'content-type': 'application/octet-stream' });
+ res.end(Buffer.alloc(3 * 1024 * 1024, 7));
+ return;
+ }
+ if (req.url === '/cookie') {
+ res.writeHead(200, { 'set-cookie': ['app=1; Domain=.openvibe.website; Path=/', '__Host-ovc=forged; Path=/; Secure'], 'cache-control': 'public, max-age=600' });
+ res.end('ok');
+ return;
+ }
+ res.writeHead(200, { 'content-type': 'application/json', 'x-app': 'yes' });
+ res.end(JSON.stringify({ method: req.method, url: req.url, user: req.headers['x-openvibe-user'] || null, host: req.headers.host, cookie: req.headers.cookie || null, body: Buffer.concat(chunks).toString('utf8') }));
+ });
+ });
+ server.on('upgrade', (req, socket) => {
+ seen.push({ upgrade: true, url: req.url, headers: req.headers });
+ socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: echo\r\nConnection: Upgrade\r\n\r\n');
+ socket.on('data', (d) => socket.write(Buffer.concat([Buffer.from('echo:'), d])));
+ });
+ const sockets = new Set();
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({ port: server.address().port, seen, close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }) }));
+ });
+}
+
+module.exports = { boot, startLocalApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+
+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); }
+function assert(cond, message) { if (!cond) throw new Error(message || 'assertion failed'); }
+function equal(a, b, message) { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${message || 'not equal'}: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); }
+module.exports = { check, done, assert, equal };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * CONNECT_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.CONNECT_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'connect', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'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 `connect`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Connect, 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: [], 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 !== 'connect' || body.client_secret !== 'connect-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:connect', 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 });
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setDown(v) { st.down = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * The pages without JavaScript: the front door for a guest and for a person, making a share with the form (the key
+ * shown on that page only), a refused name keeping what was typed, the share page, who can open it, a new key,
+ * closing, the grant's "not for you" page, cross-site writes refused, and another person's share is a 404.
+ */
+const assert = require('assert');
+const { boot } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ let id;
+
+ await check('a guest sees what Connect is and a sign-in to start; no form', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /Your computer, on the web, privately/);
+ assert.match(r.text, /Sign in to make a share/);
+ assert.ok(!r.text.includes('action="/shares"'));
+ assert.match(r.text, /<meta name="robots" content="index, follow"/);
+ });
+
+ await check('signed in: your shares and the form, with your own address in it', async () => {
+ const r = await t.get('/', { as: kim });
+ assert.match(r.text, /No shares yet/);
+ assert.match(r.text, /action="\/shares"/);
+ assert.match(r.text, /--kim\.openvibe\.website/);
+ });
+
+ await check('the form makes a share and shows its key once, with the commands', async () => {
+ const r = await t.get('/shares', { as: kim, form: { label: 'portal', port: '8790', mode: 'owner', named: '' } });
+ assert.strictEqual(r.status, 201, r.text.slice(0, 300));
+ const key = r.text.match(/ovc_[A-Za-z0-9_-]{43}/)[0];
+ assert.match(r.text, /Your connect key/);
+ assert.ok(r.text.includes(`node ov-connect.js --key ${key} --port 8790`));
+ assert.match(r.text, /curl -fsSLO https:\/\/connect\.openvibe\.website\/ov-connect\.js/);
+ assert.match(r.headers.get('cache-control'), /no-store|private/);
+ id = (await t.get('/api/v1/shares', { as: kim })).json().shares[0].id;
+ const again = await t.get(`/shares/${id}`, { as: kim });
+ assert.strictEqual(again.status, 200);
+ assert.ok(!again.text.includes(key), 'never shown again');
+ assert.match(again.text, /The key was shown once/);
+ });
+
+ await check('a refused name says why and keeps what was typed', async () => {
+ const r = await t.get('/shares', { as: kim, form: { label: 'portal', port: '8790', mode: 'named', named: 'sam' } });
+ assert.strictEqual(r.status, 409);
+ assert.match(r.text, /You already have a share with that name/);
+ assert.match(r.text, /value="sam"/);
+ });
+
+ await check('who can open it: saved, then shown', async () => {
+ const r = await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'named', named: '@lee, sam', port: '8791' } });
+ assert.strictEqual(r.status, 303);
+ const page = await t.get(r.headers.get('location'), { as: kim });
+ assert.match(page.text, /Saved\./);
+ assert.match(page.text, /@lee, @sam/);
+ assert.match(page.text, /localhost:8791/);
+ });
+
+ await check('another site cannot change a share (the form must come from Connect)', async () => {
+ const r = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {}, origin: 'https://evil.example' });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual((await t.get('/api/v1/shares', { as: kim })).json().shares[0].status, 'active');
+ const api = await t.get(`/api/v1/shares/${id}`, { as: kim, method: 'DELETE', origin: 'https://evil.example' });
+ assert.strictEqual(api.status, 403);
+ assert.strictEqual(api.json().code, 'connect.cross_site');
+ });
+
+ await check('another person: the share page is a 404, and the grant says "not for you"', async () => {
+ assert.strictEqual((await t.get(`/shares/${id}`, { as: lee })).status, 404);
+ await t.get(`/shares/${id}/policy`, { as: kim, form: { mode: 'owner', named: '' } });
+ const g = await t.get(`/grant?share=${id}`, { as: lee });
+ assert.strictEqual(g.status, 403);
+ assert.match(g.text, /You can.t open portal--kim\.openvibe\.website/);
+ assert.match(g.text, /Use another account/);
+ });
+
+ await check('a new key is shown once on the page; closing says so and keeps the address', async () => {
+ const k = await t.get(`/shares/${id}/key`, { as: kim, method: 'POST', form: {} });
+ assert.strictEqual(k.status, 200);
+ assert.match(k.text, /A new key is made/);
+ assert.match(k.text, /ovc_[A-Za-z0-9_-]{43}/);
+ const c = await t.get(`/shares/${id}/close`, { as: kim, method: 'POST', form: {} });
+ assert.match(c.text, /Closed\. The address answers/);
+ assert.ok(!c.text.includes('action="/shares/' + id + '/policy"'), 'a closed share has no settings');
+ const home = await t.get('/', { as: kim });
+ assert.match(home.text, /Closed shares \(1\)/);
+ });
+
+ await check('signed out, the share pages ask for sign-in first', async () => {
+ const r = await t.get(`/shares/${id}`);
+ assert.strictEqual(r.status, 303);
+ assert.match(r.headers.get('location'), /^\/auth\/login\?next=/);
+ });
+
+ await check('robots, llms.txt and the client download', async () => {
+ assert.match((await t.get('/robots.txt')).text, /Disallow: \/grant/);
+ assert.match((await t.get('/llms.txt')).text, /OpenVibe Connect/);
+ const js = await t.get('/ov-connect.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ assert.match(js.text, /ovconnect-ws\/1/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+/**
+ * The pure rules: share names and hosts, the wire frames, the gate's cookie and its "next", and the client being one
+ * file with nothing but Node's own modules.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { check, done } = require('./helpers/check');
+const { hostFor, parseHost, handleOf, normalizeNamed } = require('../server/shares');
+const frames = require('../server/edge/frames');
+const { createGate } = require('../server/edge/gate');
+
+(async () => {
+ await check('a handle is the username made DNS-safe; a host joins label and handle with "--"', async () => {
+ assert.strictEqual(handleOf('JapaneseOldGuy'), 'japaneseoldguy');
+ assert.strictEqual(handleOf('Alex_Dev.2'), 'alex-dev-2');
+ assert.strictEqual(handleOf('__'), '');
+ assert.strictEqual(hostFor('agents', 'Alex', 'openvibe.website'), 'agents--alex.openvibe.website');
+ assert.strictEqual(hostFor('a'.repeat(40), 'b'.repeat(30), 'openvibe.website'), null, 'over 63 characters');
+ assert.strictEqual(hostFor('x', '__', 'openvibe.website'), null);
+ });
+
+ await check('parseHost reads only a share host: one label, "--" once, both sides DNS-safe', async () => {
+ assert.deepStrictEqual(parseHost('Agents--Alex.openvibe.website:443', 'openvibe.website'), { host: 'agents--alex.openvibe.website', label: 'agents', handle: 'alex' });
+ const notShares = ['openvibe.website', 'connect.openvibe.website', 'mysite.openvibe.website', 'a.b--c.openvibe.website', '--x.openvibe.website',
+ 'x--.openvibe.website', 'x---y.openvibe.website', 'x--y.openvibe.website.evil.com', 'x--y.evil.com', '', null];
+ notShares.forEach((h) => assert.strictEqual(parseHost(h, 'openvibe.website'), null, String(h)));
+ });
+
+ await check('named people: lowercase, deduplicated, @ allowed, junk refused', async () => {
+ assert.deepStrictEqual(normalizeNamed('@Sam, riley sam'), ['sam', 'riley']);
+ assert.throws(() => normalizeNamed(['bad name!']), /not an OpenVibe username/);
+ });
+
+ await check('frames: data and end round-trip; anything else is dropped', async () => {
+ const f = frames.decode(frames.encode(frames.KIND.DATA, 70000, Buffer.from('hi')));
+ assert.deepStrictEqual([f.kind, f.id, f.payload.toString()], [1, 70000, 'hi']);
+ assert.strictEqual(frames.decode(frames.encode(frames.KIND.END, 3)).payload.length, 0);
+ assert.strictEqual(frames.decode(Buffer.from([9, 0, 0, 0, 1])), null);
+ assert.strictEqual(frames.decode(Buffer.from([1, 0])), null);
+ });
+
+ await check('the gate\'s cookie: bound to the share, its policy version and its expiry', async () => {
+ let now = 1_800_000_000_000;
+ const config = { shares: { sessionSecret: 'x'.repeat(40), sessionHours: 12 }, cookies: { secure: true } };
+ const gate = createGate({ config, now: () => now });
+ const share = { id: 'shr_1', policy_version: 2 };
+ const v = gate.sign({ shareId: 'shr_1', subject: 'usr_1', username: 'alex', policyVersion: 2 });
+ assert.deepStrictEqual(gate.verify(v, share), { subject: 'usr_1', username: 'alex' });
+ assert.strictEqual(gate.verify(v, { id: 'shr_2', policy_version: 2 }), null);
+ assert.strictEqual(gate.verify(v, { id: 'shr_1', policy_version: 3 }), null);
+ assert.strictEqual(gate.verify(`${v}x`, share), null);
+ now += 13 * 3600_000;
+ assert.strictEqual(gate.verify(v, share), null, 'expired');
+ assert.strictEqual(gate.cookieName, '__Host-ovc');
+ assert.match(gate.cookieHeader('v'), /^__Host-ovc=v; Path=\/; HttpOnly; SameSite=Lax; Max-Age=43200; Secure$/);
+ assert.deepStrictEqual(gate.splitCookies('a=1; __Host-ovc=abc; ovc=old; b=2'), { ours: 'abc', rest: 'a=1; b=2' });
+ gate.stop();
+ assert.throws(() => createGate({ config: { shares: { sessionSecret: 'short' }, cookies: {} } }), /CONNECT_SESSION_SECRET/);
+ });
+
+ await check('next: a local path only', async () => {
+ const gate = createGate({ config: { shares: { sessionSecret: 'y'.repeat(40), sessionHours: 1 }, cookies: { secure: false } } });
+ const cases = [['/a?b=1', '/a?b=1'], ['//evil.com', '/'], ['/\\evil.com', '/'], ['https://evil.com', '/'], ['/x\r\nSet-Cookie: a', '/'], ['/__ovc/callback', '/'], [null, '/']];
+ cases.forEach(([inp, out]) => assert.strictEqual(gate.safeNext(inp), out, String(inp)));
+ gate.stop();
+ });
+
+ await check('the client is one file that needs nothing but Node', async () => {
+ const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'ov-connect.js'), 'utf8');
+ const mods = [...src.matchAll(/require\('([^']+)'\)/g)].map((m) => m[1]);
+ assert.deepStrictEqual(mods.sort(), ['http', 'net']);
+ assert.match(src, /^#!\/usr\/bin\/env node/);
+ const { parseArgs } = require('../public/ov-connect');
+ assert.strictEqual(parseArgs(['--key', 'ovc_x', '8080']).port, 8080);
+ assert.strictEqual(parseArgs(['--key=ovc_y', '--port=9']).key, 'ovc_y');
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+#!/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 Chat/Live/Video fixtures; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- importer # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,277 @@
@@ -0,0 +1,277 @@
+'use strict';
+/**
+ * A share end to end: made through the API, the gate (grant → one-time code → the share's own host-only cookie),
+ * the client (public/ov-connect.js) carrying requests to a real local app — bodies, server-sent events, a 3 MB
+ * download, a WebSocket-style upgrade — the headers the app gets and the ones it may not set, who may open it, a new
+ * key, closing, and the counters.
+ */
+const assert = require('assert');
+const net = require('net');
+const { boot, startLocalApp } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+async function until(fn, ms = 3000) {
+ const end = Date.now() + ms;
+ for (;;) {
+ const v = await fn();
+ if (v) return v;
+ if (Date.now() > end) throw new Error('timed out waiting');
+ await sleep(20);
+ }
+}
+
+(async () => {
+ const t = await boot();
+ const app = await startLocalApp();
+ const alex = t.network.addUser('Alex_Dev');
+ const bob = t.network.addUser('bob');
+ let made;
+ let alexCookie;
+ let c;
+
+ await check('a share is made through the API: its address joins the name and the handle with "--"; the key comes once', async () => {
+ made = await t.makeShare(alex, { label: 'agents', port: app.port });
+ assert.strictEqual(made.share.host, 'agents--alex-dev.openvibe.website');
+ assert.strictEqual(made.share.url, 'https://agents--alex-dev.openvibe.website');
+ assert.strictEqual(made.share.mode, 'owner');
+ assert.match(made.key, /^ovc_[A-Za-z0-9_-]{43}$/);
+ const list = await t.get('/api/v1/shares', { as: alex });
+ assert.strictEqual(list.json().shares.length, 1);
+ assert.ok(!list.text.includes(made.key), 'the key is never shown again');
+ assert.ok(!(await t.dbDump()).includes(made.key), 'only the key\'s hash is stored');
+ });
+
+ await check('names: public, reserved, malformed and taken names are refused', async () => {
+ const pub = await t.get('/api/v1/shares', { as: alex, json: { label: 'open', port: 80, mode: 'public' } });
+ assert.strictEqual(pub.status, 422);
+ assert.strictEqual(pub.json().code, 'share.public_not_yet');
+ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'connect', port: 80 } })).json().code, 'share.reserved_label');
+ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'a--b', port: 80 } })).json().code, 'share.invalid_label');
+ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'agents', port: 80 } })).json().code, 'share.taken');
+ assert.strictEqual((await t.get('/api/v1/shares', { as: alex, json: { label: 'x', port: 70000 } })).json().code, 'share.invalid_port');
+ });
+
+ await check('the gate: a browser goes to the grant, a script gets 401 with where to sign in', async () => {
+ const r = await t.share(made.share.host, '/hello?x=1');
+ assert.strictEqual(r.status, 302);
+ assert.strictEqual(r.headers.location, `https://connect.openvibe.website/grant?share=${made.share.id}&next=${encodeURIComponent('/hello?x=1')}`);
+ assert.strictEqual(r.headers['cloudflare-cdn-cache-control'], 'no-store');
+ const api = await t.share(made.share.host, '/data.json', { headers: { accept: 'application/json' } });
+ assert.strictEqual(api.status, 401);
+ assert.strictEqual(api.json().error, 'sign_in_required');
+ });
+
+ await check('the grant: signed out → sign in first; the owner gets a one-time code; the callback sets a host-only cookie', async () => {
+ const anon = await t.get(`/grant?share=${made.share.id}&next=/hello`);
+ assert.strictEqual(anon.status, 303);
+ assert.match(anon.headers.get('location'), /^\/auth\/login\?next=/);
+ const e = await t.enter(alex, made.share, '/hello');
+ assert.strictEqual(e.status, 303);
+ assert.strictEqual(e.location, '/hello');
+ assert.match(e.setCookie, /^ovc=[^;]+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=\d+$/, 'host-only: no Domain attribute');
+ alexCookie = e.cookie;
+ });
+
+ await check('a code works once, and only on its own share', async () => {
+ const g = await t.get(`/grant?share=${made.share.id}&next=/`, { as: alex });
+ const loc = new URL(g.headers.get('location'));
+ const first = await t.share(made.share.host, `${loc.pathname}${loc.search}`);
+ assert.strictEqual(first.status, 303);
+ const again = await t.share(made.share.host, `${loc.pathname}${loc.search}`);
+ assert.strictEqual(again.status, 400, 'used once');
+ const other = await t.makeShare(alex, { label: 'other', port: app.port });
+ const g2 = await t.get(`/grant?share=${made.share.id}&next=/`, { as: alex });
+ const loc2 = new URL(g2.headers.get('location'));
+ assert.strictEqual((await t.share(other.share.host, `${loc2.pathname}${loc2.search}`)).status, 400, 'bound to its share');
+ await t.get(`/api/v1/shares/${other.share.id}`, { as: alex, method: 'DELETE' });
+ });
+
+ await check('nothing connected: the visitor is told the computer is not connected', async () => {
+ const r = await t.share(made.share.host, '/', { cookie: alexCookie });
+ assert.strictEqual(r.status, 502);
+ assert.match(r.text, /computer is not connected/);
+ });
+
+ await check('the client connects with its key and the share answers from the local app', async () => {
+ c = t.client({ key: made.key, port: app.port });
+ const ready = await c.ready();
+ assert.strictEqual(ready.host, made.share.host);
+ const r = await t.share(made.share.host, '/hello?x=1', { cookie: `${alexCookie}; theirs=1`, headers: { accept: 'application/json', 'x-openvibe-user': 'mallory', 'x-forwarded-host': 'evil--x.openvibe.website' } });
+ assert.strictEqual(r.status, 200, r.text);
+ const b = r.json();
+ assert.strictEqual(b.url, '/hello?x=1');
+ assert.strictEqual(b.user, 'Alex_Dev', 'the app is told who, and a visitor cannot say it');
+ assert.strictEqual(b.host, `localhost:${app.port}`);
+ assert.strictEqual(b.cookie, 'theirs=1', 'the gate\'s cookie never reaches the app');
+ const seen = app.seen[app.seen.length - 1].headers;
+ assert.strictEqual(seen['x-forwarded-host'], made.share.host, 'the share\'s host, never the visitor\'s header');
+ assert.strictEqual(seen['x-openvibe-subject'], alex.subject);
+ assert.strictEqual(r.headers['x-app'], 'yes');
+ assert.strictEqual(r.headers['x-robots-tag'], 'noindex, nofollow');
+ assert.strictEqual(r.headers['cloudflare-cdn-cache-control'], 'no-store');
+ assert.match(r.headers['cache-control'], /private/);
+ });
+
+ await check('a request body goes through', async () => {
+ const r = await t.share(made.share.host, '/post', { method: 'POST', cookie: alexCookie, headers: { accept: 'application/json', 'content-type': 'text/plain' }, body: 'hello from the phone' });
+ assert.strictEqual(r.json().body, 'hello from the phone');
+ assert.strictEqual(r.json().method, 'POST');
+ });
+
+ await check('server-sent events stream as they come', async () => {
+ const times = [];
+ const r = await t.share(made.share.host, '/sse', { cookie: alexCookie, headers: { accept: 'text/event-stream' }, onResponse: (res) => res.on('data', () => times.push(Date.now())) });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers['content-type'], 'text/event-stream');
+ assert.match(r.text, /data: tick 1\n\ndata: tick 2\n\ndata: tick 3/);
+ assert.ok(times.length >= 2, 'more than one piece arrived');
+ });
+
+ await check('a 3 MB response arrives whole', async () => {
+ const r = await t.share(made.share.host, '/big', { cookie: alexCookie, headers: { accept: '*/*' } });
+ assert.strictEqual(r.buffer.length, 3 * 1024 * 1024);
+ assert.ok(r.buffer.every((x) => x === 7));
+ });
+
+ await check('a Set-Cookie loses its Domain, and the app cannot set the gate\'s own cookie', async () => {
+ const r = await t.share(made.share.host, '/cookie', { cookie: alexCookie, headers: { accept: '*/*' } });
+ const set = [].concat(r.headers['set-cookie'] || []);
+ assert.deepStrictEqual(set, ['app=1; Path=/']);
+ assert.strictEqual(r.headers['cache-control'], 'private, max-age=600', 'public turned private: nothing past the gate is cached by a CDN');
+ });
+
+ await check('a WebSocket-style upgrade is a byte pipe to the local app, after the gate', async () => {
+ const reply = await new Promise((resolve, reject) => {
+ const s = net.connect(t.port, '127.0.0.1', () => {
+ s.write(`GET /ws HTTP/1.1\r\nHost: ${made.share.host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nCookie: ${alexCookie}\r\n\r\n`);
+ });
+ let got = '';
+ s.on('data', (d) => {
+ got += d.toString();
+ if (got.includes('\r\n\r\n') && !got.includes('echo:')) s.write('ping');
+ if (got.includes('echo:ping')) { s.destroy(); resolve(got); }
+ });
+ s.on('error', reject);
+ setTimeout(() => reject(new Error(`no echo: ${JSON.stringify(got)}`)), 3000);
+ });
+ assert.match(reply, /^HTTP\/1\.1 101/);
+ const up = app.seen.find((x) => x.upgrade);
+ assert.strictEqual(up.headers.host, `localhost:${app.port}`);
+ assert.ok(!String(up.headers.cookie || '').includes('ovc='));
+ const anon = await new Promise((resolve) => {
+ const s = net.connect(t.port, '127.0.0.1', () => s.write(`GET /ws HTTP/1.1\r\nHost: ${made.share.host}\r\nConnection: Upgrade\r\nUpgrade: echo\r\nAccept: application/json\r\n\r\n`));
+ let got = '';
+ s.on('data', (d) => { got += d; });
+ s.on('close', () => resolve(got));
+ });
+ assert.match(anon, /^HTTP\/1\.1 401/, 'no cookie, no pipe');
+ });
+
+ await check('who can open it: bob is turned away, then named; changing it signs everyone out of the share', async () => {
+ const denied = await t.enter(bob, made.share);
+ assert.strictEqual(denied.status, 403);
+ assert.match(denied.grant.text, /You can.t open/);
+ const patched = await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'PATCH', json: { mode: 'named', named: ['Bob'] } });
+ assert.strictEqual(patched.status, 200, patched.text);
+ assert.deepStrictEqual(patched.json().share.named, ['bob']);
+ assert.strictEqual(patched.json().share.policy_version, made.share.policy_version + 1);
+ const stale = await t.share(made.share.host, '/', { cookie: alexCookie });
+ assert.strictEqual(stale.status, 302, 'the old cookie no longer opens it');
+ const inBob = await t.enter(bob, made.share);
+ assert.strictEqual(inBob.status, 303);
+ const r = await t.share(made.share.host, '/', { cookie: inBob.cookie, headers: { accept: 'application/json' } });
+ assert.strictEqual(r.json().user, 'bob');
+ alexCookie = (await t.enter(alex, made.share)).cookie;
+ });
+
+ await check('a cookie that was tampered with or made for another share does not open it', async () => {
+ const [data, sig] = alexCookie.slice(4).split('.');
+ const claims = JSON.parse(Buffer.from(data, 'base64url').toString());
+ claims.u = bob.subject;
+ const forged = `ovc=${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${sig}`;
+ assert.strictEqual((await t.share(made.share.host, '/', { cookie: forged })).status, 302);
+ });
+
+ await check('nothing listening on the port: the visitor is told so', async () => {
+ const dead = await t.makeShare(alex, { label: 'dead', port: 9 });
+ const cDead = t.client({ key: dead.key, port: 9 });
+ await cDead.ready();
+ const cookie = (await t.enter(alex, dead.share)).cookie;
+ const r = await t.share(dead.share.host, '/', { cookie });
+ assert.strictEqual(r.status, 502);
+ assert.match(r.text, /nothing answered on port 9/);
+ cDead.stop();
+ });
+
+ await check('a wrong key is refused and the client stops', async () => {
+ const bad = t.client({ key: `ovc_${'A'.repeat(43)}`, port: app.port });
+ await until(() => bad.lines.some((l) => /Stopped/.test(l)));
+ assert.ok(bad.lines.some((l) => /not one we know/.test(l)));
+ });
+
+ await check('a new key disconnects the computer and the old key stops working', async () => {
+ const r = await t.get(`/api/v1/shares/${made.share.id}/key`, { as: alex, method: 'POST' });
+ assert.strictEqual(r.status, 200);
+ const fresh = r.json().key;
+ assert.notStrictEqual(fresh, made.key);
+ await until(() => c.lines.some((l) => /Stopped/.test(l)));
+ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), false);
+ const old = t.client({ key: made.key, port: app.port });
+ await until(() => old.lines.some((l) => /Stopped/.test(l)));
+ c = t.client({ key: fresh, port: app.port });
+ await c.ready();
+ assert.strictEqual(t.ctx.tunnels.isConnected(made.share.id), true);
+ });
+
+ await check('a second client for the same share takes over; the first stops', async () => {
+ const key = (await t.get(`/api/v1/shares/${made.share.id}/key`, { as: alex, method: 'POST' })).json().key;
+ const first = t.client({ key, port: app.port });
+ await first.ready();
+ const second = t.client({ key, port: app.port });
+ await second.ready();
+ await until(() => first.lines.some((l) => /took over/.test(l)));
+ const r = await t.share(made.share.host, '/', { cookie: alexCookie, headers: { accept: 'application/json' } });
+ assert.strictEqual(r.status, 200);
+ c = second;
+ });
+
+ await check('the counters: requests, bytes and who opened it reach the share page', async () => {
+ await t.ctx.proxy.flush();
+ const s = (await t.get(`/api/v1/shares/${made.share.id}`, { as: alex })).json().share;
+ assert.ok(s.requests >= 6, `requests ${s.requests}`);
+ assert.ok(s.bytes_out >= 3 * 1024 * 1024);
+ const v = (await t.get(`/api/v1/shares/${made.share.id}/visits`, { as: alex })).json().visits;
+ const names = v.map((x) => x.username).sort();
+ assert.deepStrictEqual(names, ['Alex_Dev', 'bob']);
+ assert.ok(v.find((x) => x.username === 'bob').denied >= 1, 'bob was turned away once');
+ assert.strictEqual(s.connected, true);
+ });
+
+ await check('another person cannot see, change or close someone else\'s share', async () => {
+ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/shares/${made.share.id}`, { as: bob, method: 'DELETE' })).status, 404);
+ assert.strictEqual((await t.get(`/shares/${made.share.id}`, { as: bob })).status, 404);
+ });
+
+ await check('closing: the computer is disconnected and the address answers 410; nobody else can take it', async () => {
+ const r = await t.get(`/api/v1/shares/${made.share.id}`, { as: alex, method: 'DELETE' });
+ assert.strictEqual(r.json().share.status, 'revoked');
+ await until(() => !t.ctx.tunnels.isConnected(made.share.id));
+ const gone = await t.share(made.share.host, '/', { cookie: alexCookie });
+ assert.strictEqual(gone.status, 410);
+ assert.match(gone.text, /closed/);
+ const again = await t.get('/api/v1/shares', { as: alex, json: { label: 'agents', port: app.port } });
+ assert.strictEqual(again.status, 201, 'the owner may reuse their own address');
+ });
+
+ await check('a host that is not a share\'s, or a share that does not exist, says so', async () => {
+ assert.strictEqual((await t.share('nobody--here.openvibe.website', '/')).status, 404);
+ const page = await t.get('/', { headers: { host: 'connect.openvibe.website' } });
+ assert.strictEqual(page.status, 200);
+ });
+
+ await t.close();
+ await app.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * Sign-in through openvibe-sdk/sso (PKCE S256, client id connect; the SDK's cookies ov_token/ov_refresh/ov_oauth_state,
+ * and connect_at read as the session), the session-token rules (a FedCM assertion or an app token is not a
+ * session), the open-redirect guard, /auth/health, truthful readiness, loopback metrics, pages without JavaScript, and
+ * cross-site sign-out.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot } = require('./helpers/boot');
+const { check, done } = require('./helpers/check');
+const { sanitizeNext } = require('../server/auth/sso');
+const cookieJar = (res) => res.headers.getSetCookie().map((c) => c.split(';')[0]).join('; ');
+
+(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 connect', async () => {
+ const r = await t.get('/auth/login?next=/shares/shr_x');
+ 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'), 'connect');
+ 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'), /^[A-Za-z0-9_-]{32}$/);
+ assert.match(r.headers.get('set-cookie'), /ov_oauth_state=.*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=/shares/shr_x');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = cookieJar(login);
+ 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'), '/shares/shr_x');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /ov_token=[^;]+;.*HttpOnly/i);
+ assert.match(set, /ov_refresh=[^;]+;.*HttpOnly/i);
+ assert.match(set, /ov_oauth_state=;/);
+ const at = set.match(/ov_token=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `ov_token=${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 starts the sign-in once more', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = cookieJar(login);
+ 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, 302, 'the SDK recovers by itself once');
+ assert.match(bad.headers.get('location'), /^\/auth\/login/);
+ const retry = cookieJar(bad);
+ assert.match(retry, /ov_oauth_retry=1/);
+ const again = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: `${flowCookie}; ${retry}` });
+ assert.strictEqual(again.status, 400);
+ assert.match(again.text, /Try again/);
+ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
+ assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 302);
+ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 302);
+ });
+
+ await check('next= only accepts same-site paths', async () => {
+ const r = await t.get('/auth/login?next=//evil.example/x');
+ assert.ok(/ov_oauth_next=;/.test(String(r.headers.get('set-cookie') || '')), 'a cross-site next is dropped');
+ const opts = { baseUrl: 'https://connect.openvibe.website', networkUrl: 'https://openvibe.network' };
+ assert.strictEqual(sanitizeNext('/shares/shr_x?x=1', opts), '/shares/shr_x?x=1');
+ assert.strictEqual(sanitizeNext('https://connect.openvibe.website/drops?y=1', opts), 'https://connect.openvibe.website/drops?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://connect.openvibe.website.evil.com/x']) assert.strictEqual(sanitizeNext(v, opts), '/', v);
+ });
+
+ await check('/auth/health says whether the Network accepts this client (the sign-in canary)', async () => {
+ const r = await t.get('/auth/health');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json(), { ok: true, client: 'ok' });
+ });
+
+ await check('the old cookie is still the session, so nobody is signed out by the move', async () => {
+ const me = await t.get('/auth/me', { cookie: `connect_at=${t.network.userToken(user)}` });
+ assert.strictEqual(me.status, 200);
+ assert.strictEqual(me.json().user.username, 'kim');
+ });
+
+ await check('readiness is truthful: db required; JWKS, the OAuth client 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, 'connect');
+ 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 ['/']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript>/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API asks for a person, and unknown routes are problem+json 404', async () => {
+ const anon = await t.get('/api/v1/shares');
+ assert.strictEqual(anon.status, 401);
+ assert.strictEqual(anon.json().code, 'token.required');
+ const gone = await t.get('/api/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: `connect_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `connect_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: `connect_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: 'connect_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'connect_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(!/connect_at=;|connect_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=/shares', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/shares');
+ assert.match(String(same.headers.get('set-cookie') || ''), /connect_at=;/);
+ });
+
+ await t.close();
+ done();
+})();