openvibers/openvibe.exchange Commits
OpenVibe.Exchange v1
Changes
nnew file mode 100644@@@ -0,0 +1,49 @@++# OpenVibe.Exchange — environment (production: /etc/openvibe/exchange.env). Names only; never commit values.++++NODE_ENV=production++PORT=5150++HOST=127.0.0.1++BASE_URL=https://openvibe.exchange++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,++# the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:exchange:++# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).++#EXCHANGE_LIMITS_MINUTE=120++#EXCHANGE_LIMITS_HOUR=3000++#EXCHANGE_API_RATE_LIMIT_PER_MIN=240++#EXCHANGE_RATE_LIMIT_PER_MIN=300++++# OpenVibe.Network: SSO (OAuth client `exchange`, PKCE S256) and its JWKS; app tokens for audience++# openvibe.exchange (no v1 route needs one).++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OV_NETWORK_ISSUER=https://openvibe.network++OV_OAUTH_CLIENT_ID=exchange++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.exchange/auth/callback++# EXCHANGE_AUDIENCE=openvibe.exchange++# OV_SESSION_AUDIENCE=openvibe.network++# EXCHANGE_PGLITE_DIR=./data/pglite++++# The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and is++# required (once) before an install; a person gets at most this many trials a day.++#EXCHANGE_SANDBOX_TTL_MINUTES=60++#EXCHANGE_SANDBOX_PER_DAY=20++++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).++EXCHANGE_EVENTS_SECRET=++EXCHANGE_EVENTS_URL=++#EXCHANGE_EVENTS_ENDPOINT=http://127.0.0.1:5150/internal/events++#EXCHANGE_EVENTS_INTERVAL_MS=5000++++# A restore drill starts no subscriptions and creates no Events subscription.++# EXCHANGE_DRILL=0++++# optional: defaults to true in production++# COOKIE_SECURE=truennew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test 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,42 @@++# Changelog++++What changed in OpenVibe.Exchange, newest first.++++## 0.1.0 — 2026-10-28++++The first version: trade what people make, free and swap only.++++- **Listings.** `exchange_listings` with a unique slug, kind (`agent`, `mcp_tool`, `template`, `theme`, `game_asset`,++ `service`), free/swap mode, SPDX licence, semantic version, public manifest, permission passport and a revision++ that goes up on every change. There is no price, currency or Billing column; a passport's `spends` is `[]` and a++ table check, the form and the API all enforce it.++- **The passport.** Reads, writes, spends and contacts, declared in the manifest and shown before and after install.++ An install stores its own snapshot, so a later edit cannot widen an old install. A manifest that looks like a++ credential is refused.++- **Conformance tests.** Submitting a draft runs `manifest`, `passport`, `licence` and `no_money` for its version and++ stores one row each. Only the owner can publish, and only when all four passed.++- **Sandbox.** A trial is a manifest/passport dry run with four recorded checks, a 60-minute TTL and at most 20 a++ day. A passed trial is consumed by the install. v1 does not execute the listing's code; a real Run sandbox is the++ next version's job and the page says so.++- **Install and revoke.** `exchange_installs` with one active install per (listing, person), the passport snapshot,++ a version and a revision. Revoke is one tap, idempotent, and a fresh trial is needed to install again.++- **Free and swap.** A free listing takes claims (`exchange_offers` kind `claim`); a swap listing says what it wants++ in return and takes offers with a required `give`. The owner accepts or declines; the sender withdraws. Accepting++ closes the listing as `given` or `swapped`, declines the other open offers and tombstones its Search document.++- **Safety.** The fixed prohibited-items and no-money filter is applied to every listing and offer, in the form and++ the API alike; `/safety` carries the local-swap rules.++- **Search.** Each public listing is one `search.index-document@1` (owner `exchange`, type `listing`,++ `/listings/<slug>`) queued in `exchange_outbox` in the same transaction; closing or hiding writes a tombstone. The++ relay waits for the `search.document.write` grant.++- **Account data.** Listings, installs, sandbox trials and offers are exported and erased with an account. A deleted++ listing has its words replaced, is closed and is tombstoned in Search; a draft is deleted outright; other people's++ listings stay.++- **The service.** Network sign-in as OAuth client `exchange` (PKCE S256, `exchange_at`/`exchange_rt`/++ `exchange_oauth`), truthful readiness, Prometheus metrics on loopback, discovery, deploy files, and++ server-rendered pages with stable `ex-` ids. `public/css/exchange.css` and `public/js/exchange.js` are one-comment++ placeholders for the main session.++- **The first proof.** `scripts/seed-proof.js` lists a CC0-1.0 Website template, runs its conformance tests, tries it++ in the sandbox, installs it with its passport and revokes that install; it is idempotent.++- **Tests.** Migration and constraints, the API, sandbox/install/revoke, free/swap offers, pages and cache, SSO,++ Search, account export/deletion, noLooks, safety and the first proof — each file in its own process on a temporary++ PGlite database with a mock Network.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,196 @@++# OpenVibe.Exchange++++> Trade what people make.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `openvibe.exchange` · **Port:** 5150 · **Service id:** `exchange` · **Env prefix:** `EXCHANGE`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Exchange is the OpenVibe marketplace for agents, MCP tools, templates (Website, Report, Space), design++themes and kits, game assets and creator services. v1 is **free and swap only**: every listing shows a permission++passport, can be tried in a sandbox, is installed with the passport snapshot, and is revoked in one tap. There is no++money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passport may not declare a spend.++++## Purpose++++- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version and++ manifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,++ enforced by the form, the API and the table's check constraint.++- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,++ `no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.++- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a++ one-hour pass; a passed trial is consumed by the install. v1 **does not execute a listing's code**: Run has no++ sandbox today, and the page, the API and the tests say so.++- **Install and revoke.** An install keeps the exact passport it was made with, so a later listing edit cannot widen++ an old install. A revoke marks it revoked on the spot and is idempotent. In v1 the Exchange install record is the++ entitlement; the Network grant write/revoke is listed under "Grants it will need".++- **Free and swap.** A free listing takes claims; a swap listing says what it wants in return and takes offers with++ what the other person gives. Accepting one closes the listing as `given` or `swapped`, declines the other open++ offers and tombstone its Search document. There is no escrow and no payment.++- **Safety.** A fixed prohibited-items list (weapons, illegal drugs, malware/stolen access, sexual services or++ content, forged documents, live animals, money) is applied to every listing and offer. The safety page carries the++ local-swap rules.++- **No AI.** v1 writes no model-generated text and holds no AI capability. `server/text/no-looks.js` is the shared++ `noLooks` helper (the same rule as Live, Community, MediaHub and Meme) for the day a generated summary or sandbox++ line is added; a maker's own words are never rewritten.++++## Owns++++| Table | Holds | Migration |++|---|---|---|++| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), revision, listed/closed timestamps | `migrations/0001_initial.sql` |++| `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` |++| `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |++| `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |++| `exchange_installs` | `exi_` + ULID, listing, subject, version, passport snapshot, `active\|revoked`, installed/revoked, revision; one active per (listing, subject) | `0001_initial.sql` |++| `exchange_offers` | `exo_` + ULID, listing, from subject and display name, `claim\|swap`, body, give, `open\|accepted\|declined\|withdrawn`, timestamps; one open per (listing, sender) | `0001_initial.sql` |++| `exchange_outbox` | the Search document queue (`evt_` id, topic, payload, attempts, lease) | `0001_initial.sql` |++| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |++| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |++++There is deliberately **no price, currency, amount or Billing id column**. v2 adds paid listings through Billing;++v1 cannot represent one.++++## Does not own++++- **Identity, roles and grants** — OpenVibe.Network. Exchange verifies session tokens against its JWKS; a staff route++ asks for `global_mod` or above. A passport is enforced by Network grants in the plan; v1 cannot create one (see++ "Grants it will need").++- **The sandbox runtime** — OpenVibe.Run. v1's sandbox is a dry run; a real execution needs a Run capability.++- **Search** — OpenVibe.Search. Exchange produces `search.index-document@1` documents in its own outbox; the relay++ is not wired because `exchange` has no `search.document.write` grant yet.++- **Billing, transfers, cashouts** — OpenVibe.Billing. Not used at all in v1.++- **The listings engine and local safety rules** — OpenVibe.Rent. Exchange borrows the shape of the rules (no street++ addresses, a fixed prohibited list, staff hide) but has its own table; it does not call Rent.++- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.++++## Depends on++++Environment variable names are in [.env.example](.env.example); no value is committed.++++- **OpenVibe.Network** — sign-in (OAuth client `exchange`, PKCE S256, cookies++ `exchange_at`/`exchange_rt`/`exchange_oauth`), JWKS, token exchange/refresh/revoke, account export/deletion, staff++ roles: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`,++ `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `EXCHANGE_AUDIENCE`.++- **OpenVibe.Events** — account deliveries: `EXCHANGE_EVENTS_SECRET`, `EXCHANGE_EVENTS_URL`,++ `EXCHANGE_EVENTS_ENDPOINT`.++- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in++ `EXCHANGE_PGLITE_DIR` for development.++- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).++- **Packages** — `openvibe-contracts` v0.229.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same tags++ OpenVibe.Meme pins.++++## API++++Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the++`exchange_at` session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network++JWKS. A write made with the session cookie must come from `https://openvibe.exchange`++(403 `exchange.cross_site`). A signed-in account also gets per-actor minute/hour limits (`EXCHANGE_LIMITS_MINUTE`,++`EXCHANGE_LIMITS_HOUR`).++++| Route | What it does |++|---|---|++| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, newest first; opaque keyset cursor; `Cache-Control: public, max-age=60` |++| `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff |++| `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts |++| `GET /listings/:slug/tests` | the conformance tests for the version the listing names |++| `GET /listings/:slug/offers` | owner or staff: the open claims/offers |++| `GET /me/listings` | every listing of yours, all statuses |++| `POST /listings` | signed in: create a draft; `spends` must be empty and a credential-looking manifest is refused |++| `POST /listings/:slug/list` | owner: publish after every conformance test for the version passed |++| `POST /listings/:slug/close` | owner: `{ reason: "withdrawn"\|"given"\|"swapped" }`; tombstones the Search document |++| `POST /listings/:slug/sandbox` | signed in: run a manifest dry run; a passed trial is consumed by an install |++| `GET /sandbox/:id` | your trial and its checks |++| `POST /listings/:slug/install` | signed in: install with the passport snapshot; 409 `exchange.sandbox_required` without a fresh passed trial |++| `GET /installs` | your installations, active and revoked |++| `POST /installs/:id/revoke` | revoke in one tap; idempotent |++| `POST /listings/:slug/offers` | signed in: `{ body, give? }` — a claim on a free listing or a swap offer with what you give |++| `GET /offers` | the offers you sent and received |++| `POST /offers/:id/accept\|decline\|withdraw` | owner accepts/declines; sender withdraws; accepting closes the listing |++| `POST /listings/:slug/hide` | staff: hide a listing and tombstone it |++++Validation refusals are `400 { code: "exchange.invalid" }`. Other codes: `exchange.no_money`,++`exchange.prohibited`, `exchange.manifest_secret`, `exchange.sandbox_required`, `exchange.sandbox_limit`,++`exchange.offer_exists`, `exchange.own_listing`, `exchange.owner_only`, `exchange.cross_site`,++`exchange.sign_in_required`, `exchange.staff_required`, `exchange.not_found`, `exchange.rate_limited`.++++## The permission passport++++A passport is public, declarative metadata:++++```json++{++ "reads": ["search.read", "space.files.read"],++ "writes": ["space.files.write"],++ "spends": [],++ "contacts": ["https://openvibe.space"]++}++```++++- Reads and writes are lower-case permission names; contacts are `https` origins; spends must be empty in v1.++- The manifest is `{ "name", "entry", "runtime" }` only. It is public metadata and never a credential: an unknown++ key or a value that looks like a secret is refused (`exchange.manifest_secret`).++- The listing page shows the passport before and after install; an install stores its own copy.++++## The sandbox++++`POST /listings/:slug/sandbox` runs four checks: the listing is public, every conformance test for this version++passed, the passport declares no spend, and the execution step is a dry run. The trial is `passed` when all four do;++it expires after `EXCHANGE_SANDBOX_TTL_MINUTES` (60 by default); a person may run at most++`EXCHANGE_SANDBOX_PER_DAY` (20) trials a day. A passed trial is **consumed** by the install, so installing again++needs a fresh one.++++This is deliberately honest: v1 does not run a listing's code. A real sandbox needs Run's new capability (below).++++## Search++++Each public listing is one `search.index-document@1` (owner `exchange`, type `listing`, canonical page++`/listings/<slug>`) queued in `exchange_outbox` in the same transaction as the listing change. A draft writes++nothing; closing or hiding writes a tombstone. The outbox waits for `search.document.write`; the relay is not part of++v1.++++## Deploy++++`deploy/systemd/openvibe-exchange.service` (port 5150, `/opt/openvibe.exchange`,++`/etc/openvibe/exchange.env`) and `deploy/nginx/openvibe.exchange.conf` (TLS, www redirect, `/auth/me` on the API++zone, sign-in, API and a forms zone, `/metrics` and `/internal` 404 from outside). No proxy cache: the app decides++each response's cacheability.++++`npm run seed:proof` creates the first proof through the same store functions as the API: a CC0-1.0 Website template++listed, tried in the sandbox, installed with its passport and revoked. It is idempotent.++++## Grants it will need++++v1 is written so each of these is a single call site; none is used today. They are external grants on other++services, requested from the main session (this service registers no capability of its own).++++| Grant / scope | Service | Used for | State |++|---|---|---|---|++| `network.grants.write` | OpenVibe.Network | create the developer-app grant from the passport snapshot at install | **proposed**; not in contracts v0.229.0 yet |++| `network.grants.revoke` | OpenVibe.Network | revoke that grant everywhere at one-tap revoke | **proposed**; not in contracts v0.229.0 yet |++| `run.sandbox.execute` | OpenVibe.Run | execute a listing's entry point in a real sandbox and run its own tests | **proposed**; Run has no sandbox today. Existing `run.job.submit` could back it if Run adds a sandbox job class |++| `search.document.write` | OpenVibe.Search | relay the `exchange_outbox` documents | existing capability, not granted |++| `network.account.export.contribute` | OpenVibe.Network | push a person's listings/installs/trials/offers export | existing capability, not granted |++| `network.account.deletion.confirm` | OpenVibe.Network | confirm the erasure and counts | existing capability, not granted |++| `events.subscription.manage` | OpenVibe.Events | create the two account subscriptions | existing scope, client credentials |++| `billing.intent.create`, `billing.transfer.create`, `billing.entitlement.check`, `billing.cashout.manage`, `billing.usage.record` | OpenVibe.Billing | **v2 only**: paid listings, transfers, cashouts | not used in v1; no money |++++No paid provider and no AI capability is used. No secret is stored in the database, rendered into a page or logged.++++## Tests++++`test/` boots the app on a temporary PGlite database with an in-process mock of OpenVibe.Network. The suite covers++migration and constraints, the API and validation, sandbox/install/revoke, free/swap offers, server-rendered pages++and cache headers, SSO/PKCE and readiness, the Search document, account export/deletion, noLooks and the safety list,++and the first proof.++++```sh++npm test # every file, each in its own process++OV_ALLOW_SUITE=1 npm test++npm run test:pg # the same files through PostgreSQL + PgBouncer containers++VERBOSE=1 npm test # also print each file's output++```++++## License++++AGPL-3.0-only. See [LICENSE](LICENSE).nnew file mode 100644@@@ -0,0 +1,43 @@++# 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 listings.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.exchange, openvibe.meme, 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.Exchange holds++++- Public listings: the title, summary, description, wants, kind, mode, licence, version, public creator name and++ revision, plus the manifest (`name`, `entry`, `runtime`) and the permission passport. None of it is a credential.++- Conformance tests for each listing version and the status events of a listing.++- A person's sandbox trials, installations and the claims/offers they made or received. These are exported and++ erased with an account, along with the person's listings.++- Network session tokens only in httpOnly cookies (`exchange_at`, `exchange_rt`); no token is stored in the++ database, rendered into a page or logged.++- **No money**: there is no price, no Billing id and no payment credential anywhere in v1.++++A manifest is public metadata. A key or value that looks like a secret (`api_key=`, `token:`, a private key block)++is refused before it is stored, and an unknown manifest key is refused outright. A listing's passport spends must be++empty; a spend is refused by the form, the API and a table check.++++v1 does not execute a listing's code. The sandbox is a manifest/passport dry run, and it says so on the page and in++the API. A real sandbox needs a Run capability and is not part of this version.++++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for++`/internal` and `/metrics`.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,69 @@++{++ "repository": "OpenVibers/OpenVibe.Exchange",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Exchange: trade what people make — free and swap listings with a permission passport, conformance tests for each version, a sandbox, install with the passport snapshot and one-tap revoke",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5150",++ "domain": "openvibe.exchange",++ "contracts": "openvibe-contracts v0.229.0",++ "sdk": "openvibe-sdk v0.70.0",++ "shared": "openvibe-shared v4.31.0",++ "packages": [++ "openvibe-contracts v0.229.0",++ "openvibe-sdk v0.70.0",++ "openvibe-shared v4.31.0"++ ],++ "summary": "OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, design themes and kits, game assets and creator services. v1 is free and swap only — there is no price, no Billing and no money — and every listing carries a permission passport (reads, writes, spends, contacts) taken from its public manifest. Submitting a listing runs four conformance tests for its version and a draft is only publishable when they pass. A sandbox trial is a manifest/passport dry run (v1 does not execute a listing's code); a passed trial is consumed by the install, which keeps the exact passport snapshot. A revoke is one tap and idempotent. A free listing takes claims and a swap listing takes offers with what the other person gives; accepting closes it as given or swapped. Every public listing is one Search Fabric document queued in the service's outbox, with a tombstone on close or hide.",++ "works": [++ "Listings with a unique slug, kind, free/swap mode, SPDX licence and URL, semantic version, manifest and permission passport; no price or money column",++ "A passport with reads, writes, spends and contacts; spends must be empty (a table check, the form and the API agree)",++ "Four conformance tests per version (manifest, passport, licence, no_money) stored in exchange_listing_tests; only the owner can publish and only when all pass",++ "Sandbox trials as a manifest/passport dry run with four recorded checks, a TTL and a daily cap; a passed trial is consumed by install",++ "Installs with the exact passport snapshot, one active per (listing, person), and one-tap idempotent revoke",++ "Free claims and swap offers with a required give on swaps; the owner accepts/declines, the sender withdraws; accepting closes the listing and tombstones it",++ "A fixed prohibited-items and no-money filter on every listing and offer",++ "The API: public cursor-paged listings, detail, passport, tests, owner drafts, sandbox, install, installs, offers and staff hide",++ "Search Fabric documents (owner exchange, type listing, /listings/<slug>) in exchange_outbox, with a tombstone when closed or hidden",++ "Sign-in with OpenVibe.Network (PKCE S256, exchange_at/exchange_rt/exchange_oauth), staff role global_mod+",++ "Account export and deletion over listings, installs, sandbox trials and offers; a deleted listing is anonymised, closed and tombstoned",++ "Server-rendered pages with stable ex- ids/classes, cache headers, discovery artifacts, deploy files and the first-proof seed"++ ],++ "notYet": [++ "The Network grant write/revoke: v1 keeps the install as an Exchange record; network.grants.write and network.grants.revoke are proposed and not in contracts v0.229.0",++ "A real sandbox: v1 is a manifest/passport dry run; executing a listing needs OpenVibe.Run and a run.sandbox.execute capability",++ "The Search document relay: documents wait in exchange_outbox until Network grants exchange search.document.write",++ "Paid listings through Billing (v2): no intent, transfer, cashout, entitlement or usage call exists in v1",++ "Reviews and mutual reviews; the listing page shows no reviews yet",++ "A user report form and moderation queue beyond the fixed filter and staff hide",++ "Open data/API listings, Rates and price comparisons (Zone/Services/Tools)",++ "Skill swaps on a time bank, city free-stuff hubs, a community white-label exchange, and Inventory gifting",++ "Developer-app/agent routes: an app token is recognised but no v1 route needs one"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "free and swap listings",++ "permission passport",++ "conformance tests per version",++ "sandbox dry run",++ "install with passport snapshot",++ "one-tap revoke",++ "free claims and swap offers",++ "prohibited-items and no-money filter",++ "Search Fabric outbox",++ "account export and deletion",++ "Network sign-in",++ "staff hide",++ "server-rendered pages",++ "discovery",++ "deploy files",++ "first-proof seed"++ ],++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-exchange.service and deploy/nginx/openvibe.exchange.conf",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.exchange — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.exchange.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5150, systemd unit openvibe-exchange) renders every page and serves++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses++## text.++##++## No proxy_cache: the app decides cacheability per response (public for the public pages and listings; private,++## no-store for anything drawn for a signed-in person). Never log request bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=exchange_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=exchange_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=exchange_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.exchange www.openvibe.exchange;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.exchange$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.exchange;++++ ssl_certificate /etc/letsencrypt/live/openvibe.exchange/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.exchange/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.exchange$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.exchange;++++ ssl_certificate /etc/letsencrypt/live/openvibe.exchange/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.exchange/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.exchange.access.log;++ error_log /var/log/nginx/openvibe.exchange.error.log;++++ # The API's JSON body cap is 64 KB; leave room for the headers.++ client_max_body_size 128k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints ───────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ limit_req zone=exchange_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ location /auth/ {++ limit_req zone=exchange_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=exchange_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ # ── Forms: submit, publish/close, sandbox, install, revoke, offers ──++ location ~ ^/(submit|listings/[a-z0-9-]+/(list|close|sandbox|install|offers)|installs/[A-Za-z0-9_]+/revoke|offers/[A-Za-z0-9_]+/(accept|decline|withdraw))$ {++ limit_req zone=exchange_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5150;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5150/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5150/internal/events directly. Never proxied from outside (ADR-033).++ location ~* ^/internal(/|$) { return 404; }++++ # ── Pages, docs, robots.txt, sitemap, static assets ─────++ location / {++ proxy_pass http://127.0.0.1:5150;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe.Exchange — trade what people make.++# Network (sign-in, JWKS, account events) should be up first, but Exchange degrades honestly without it: the public++# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.exchange++EnvironmentFile=/etc/openvibe/exchange.env++Environment=NODE_ENV=production++Environment=PORT=5150++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.exchange/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++# In-flight requests get the 10 s drain.++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# OpenVibe.Exchange'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,218 @@++-- phase: expand++-- OpenVibe.Exchange v1: listings with a permission passport, a sandbox, install/revoke, and free/swap only. There is++-- no money here: no price, no Billing id, and a passport may not declare a spend. A listing's manifest is public++-- metadata (name, entry, runtime) and never a credential. An install keeps the exact passport it was installed++-- with, so a later listing edit cannot widen an old install.++++CREATE TABLE exchange_listings (++ id text COLLATE "C" PRIMARY KEY, -- exl_ + 26 Crockford base32 characters (ULID)++ slug text COLLATE "C" NOT NULL UNIQUE, -- /listings/<slug>++ title text NOT NULL,++ kind text COLLATE "C" NOT NULL, -- agent | mcp_tool | template | theme | game_asset | service++ mode text COLLATE "C" NOT NULL, -- free | swap (v1: no money)++ summary text NOT NULL,++ description text,++ wants text, -- what a swap would like in return; NULL for free++ license text COLLATE "C" NOT NULL, -- SPDX identifier++ license_url text,++ version text COLLATE "C" NOT NULL, -- semver, the version the tests and passport belong to++ creator text NOT NULL, -- the maker's public display name, never their subject id++ owner text COLLATE "C", -- usr_…; NULL after an account deletion anonymises it++ status text COLLATE "C" NOT NULL DEFAULT 'draft', -- draft | listed | hidden | closed++ manifest jsonb NOT NULL, -- { name, entry, runtime } only; never a secret++ passport jsonb NOT NULL, -- { reads, writes, spends, contacts }++ revision bigint NOT NULL DEFAULT 1, -- +1 on every change; the Search revision++ listed_at timestamptz,++ closed_at timestamptz,++ closed_reason text COLLATE "C", -- given | swapped | withdrawn++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT exchange_listings_id CHECK (id ~ '^exl_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_listings_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),++ CONSTRAINT exchange_listings_title CHECK (length(title) BETWEEN 3 AND 120),++ CONSTRAINT exchange_listings_kind CHECK (kind IN ('agent', 'mcp_tool', 'template', 'theme', 'game_asset', 'service')),++ CONSTRAINT exchange_listings_mode CHECK (mode IN ('free', 'swap')),++ CONSTRAINT exchange_listings_summary CHECK (length(summary) BETWEEN 3 AND 600),++ CONSTRAINT exchange_listings_description CHECK (description IS NULL OR length(description) <= 4000),++ CONSTRAINT exchange_listings_wants CHECK (++ (mode = 'free' AND wants IS NULL) OR (mode = 'swap' AND length(wants) BETWEEN 3 AND 300)++ ),++ CONSTRAINT exchange_listings_license CHECK (license ~ '^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$'),++ CONSTRAINT exchange_listings_license_url CHECK (license_url IS NULL OR license_url ~ '^https://'),++ CONSTRAINT exchange_listings_version CHECK (version ~ '^[0-9]+\.[0-9]+\.[0-9]+$'),++ CONSTRAINT exchange_listings_creator CHECK (length(creator) BETWEEN 1 AND 80),++ CONSTRAINT exchange_listings_owner CHECK (owner IS NULL OR owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_listings_status CHECK (status IN ('draft', 'listed', 'hidden', 'closed')),++ CONSTRAINT exchange_listings_closed_reason CHECK (closed_reason IS NULL OR closed_reason IN ('given', 'swapped', 'withdrawn')),++ CONSTRAINT exchange_listings_listed_at CHECK (status <> 'listed' OR listed_at IS NOT NULL),++ CONSTRAINT exchange_listings_manifest CHECK (++ jsonb_typeof(manifest) = 'object'++ AND jsonb_typeof(manifest->'name') = 'string'++ AND jsonb_typeof(manifest->'entry') = 'string'++ ),++ CONSTRAINT exchange_listings_passport CHECK (++ jsonb_typeof(passport) = 'object'++ AND jsonb_typeof(passport->'reads') = 'array'++ AND jsonb_typeof(passport->'writes') = 'array'++ AND jsonb_typeof(passport->'contacts') = 'array'++ AND passport->'spends' = '[]'::jsonb++ ),++ CONSTRAINT exchange_listings_revision CHECK (revision > 0)++);++CREATE INDEX exchange_listings_public ON exchange_listings (status, listed_at DESC, id DESC);++CREATE INDEX exchange_listings_by_kind ON exchange_listings (status, kind, listed_at DESC);++CREATE INDEX exchange_listings_by_mode ON exchange_listings (status, mode, listed_at DESC);++CREATE INDEX exchange_listings_by_owner ON exchange_listings (owner, created_at DESC);++++-- The conformance tests run when a listing is submitted, one row per version. A listing is not public until all++-- of them pass for the version it names. (A Run sandbox executes the listing's own tests in v2; v1 checks the++-- manifest, passport, licence and the no-money rule.)++CREATE TABLE exchange_listing_tests (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,++ version text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL, -- manifest | passport | licence | no_money++ status text COLLATE "C" NOT NULL, -- passed | failed++ detail text,++ ran_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT exchange_listing_tests_status CHECK (status IN ('passed', 'failed')),++ CONSTRAINT exchange_listing_tests_name CHECK (name IN ('manifest', 'passport', 'licence', 'no_money')),++ CONSTRAINT exchange_listing_tests_unique UNIQUE (listing_id, version, name)++);++CREATE INDEX exchange_listing_tests_by_listing ON exchange_listing_tests (listing_id, version, ran_at DESC);++++-- A sandbox trial is a manifest dry run: the passport is checked and the result recorded, but no listing code is++-- executed (Run has no sandbox today). A passed trial expires; an install needs one for the same version.++CREATE TABLE exchange_sandbox_trials (++ id text COLLATE "C" PRIMARY KEY, -- exs_ + ULID++ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL, -- usr_…++ version text COLLATE "C" NOT NULL,++ status text COLLATE "C" NOT NULL, -- passed | failed++ passport jsonb NOT NULL,++ checks jsonb NOT NULL, -- array of { name, status, detail }++ created_at timestamptz NOT NULL DEFAULT now(),++ expires_at timestamptz NOT NULL,++ consumed_at timestamptz,++ CONSTRAINT exchange_sandbox_trials_id CHECK (id ~ '^exs_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_sandbox_trials_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_sandbox_trials_status CHECK (status IN ('passed', 'failed')),++ CONSTRAINT exchange_sandbox_trials_window CHECK (expires_at > created_at)++);++CREATE INDEX exchange_sandbox_trials_by_subject ON exchange_sandbox_trials (subject, created_at DESC);++CREATE INDEX exchange_sandbox_trials_for_install ON exchange_sandbox_trials (listing_id, subject, version, status, expires_at);++++-- One active installation per person and listing. The passport column is the snapshot the install was made with.++-- v1 has no Network grant to create; this record is the entitlement, and revoke removes it everywhere in Exchange.++CREATE TABLE exchange_installs (++ id text COLLATE "C" PRIMARY KEY, -- exi_ + ULID++ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL, -- usr_…++ version text COLLATE "C" NOT NULL,++ passport jsonb NOT NULL,++ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | revoked++ installed_at timestamptz NOT NULL DEFAULT now(),++ revoked_at timestamptz,++ revision bigint NOT NULL DEFAULT 1,++ CONSTRAINT exchange_installs_id CHECK (id ~ '^exi_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_installs_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_installs_status CHECK (status IN ('active', 'revoked')),++ CONSTRAINT exchange_installs_revoked CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)),++ CONSTRAINT exchange_installs_revision CHECK (revision > 0)++);++CREATE UNIQUE INDEX exchange_installs_active ON exchange_installs (listing_id, subject) WHERE status = 'active';++CREATE INDEX exchange_installs_by_subject ON exchange_installs (subject, installed_at DESC);++++-- A free claim or a swap offer. kind follows the listing's mode; a person has at most one open offer per listing.++CREATE TABLE exchange_offers (++ id text COLLATE "C" PRIMARY KEY, -- exo_ + ULID++ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,++ from_subject text COLLATE "C" NOT NULL, -- usr_…++ from_name text NOT NULL,++ kind text COLLATE "C" NOT NULL, -- claim | swap++ body text NOT NULL,++ give text, -- what a swap offers in return++ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | accepted | declined | withdrawn++ created_at timestamptz NOT NULL DEFAULT now(),++ decided_at timestamptz,++ CONSTRAINT exchange_offers_id CHECK (id ~ '^exo_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_offers_subject CHECK (from_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT exchange_offers_name CHECK (length(from_name) BETWEEN 1 AND 80),++ CONSTRAINT exchange_offers_kind CHECK (kind IN ('claim', 'swap')),++ CONSTRAINT exchange_offers_body CHECK (length(body) BETWEEN 3 AND 1000),++ CONSTRAINT exchange_offers_give CHECK (give IS NULL OR length(give) BETWEEN 3 AND 1000),++ CONSTRAINT exchange_offers_status CHECK (status IN ('open', 'accepted', 'declined', 'withdrawn'))++);++CREATE UNIQUE INDEX exchange_offers_one_open ON exchange_offers (listing_id, from_subject) WHERE status = 'open';++CREATE INDEX exchange_offers_by_listing ON exchange_offers (listing_id, status, created_at);++CREATE INDEX exchange_offers_by_sender ON exchange_offers (from_subject, created_at DESC);++++-- Every status change a listing makes (draft, listed, hidden, closed), for staff and the owner's audit.++CREATE TABLE exchange_listing_events (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,++ from_status text COLLATE "C",++ to_status text COLLATE "C" NOT NULL,++ reason text,++ changed_by text COLLATE "C", -- usr_…, account-deletion or service:exchange++ changed_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT exchange_listing_events_from CHECK (from_status IS NULL OR from_status IN ('draft', 'listed', 'hidden', 'closed')),++ CONSTRAINT exchange_listing_events_to CHECK (to_status IN ('draft', 'listed', 'hidden', 'closed'))++);++CREATE INDEX exchange_listing_events_by_listing ON exchange_listing_events (listing_id, changed_at DESC);++++-- The Search Fabric outbox (Today's shape). A document for a listed listing is written with this fixed evt_ id in++-- the same transaction as the change; a draft, hidden or closed listing writes a tombstone. No service grant is++-- needed to queue it, and the rows wait until OpenVibe.Search's document writer is configured.++CREATE TABLE exchange_outbox (++ event_id text COLLATE "C" PRIMARY KEY,++ topic text COLLATE "C" NOT NULL,++ payload jsonb NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ published_at timestamptz,++ attempts integer NOT NULL DEFAULT 0,++ last_error text,++ next_attempt_at timestamptz NOT NULL DEFAULT now(),++ claimed_at timestamptz,++ claimed_by text++);++CREATE INDEX exchange_outbox_pending ON exchange_outbox (next_attempt_at) WHERE published_at IS NULL;++++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes++-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);++++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.++CREATE TABLE telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);nnew file mode 100644@@@ -0,0 +1,1407 @@++{++ "name": "openvibe-exchange",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-exchange",++ "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.235.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.13",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.9",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.2",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.235.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",++ "integrity": "sha512-Rfmy2sDZOO+fegEPBweQvCKlamkYwOXGyr1M80VkYHXHl8gg7CnyDL7HrbGwuXG3LUcPqzhAMYeeB//jKMtzaw==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.70.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.31.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-exchange",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Exchange — trade what people make.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "seed:proof": "node scripts/seed-proof.js",++ "test": "node test/run.js",++ "test:pg": "EXCHANGE_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,130 @@++/* OpenVibe.Exchange: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent++ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone++ first; nothing scrolls sideways. A listing always shows its passport: what it reads, writes and spends. */++*, *::before, *::after { box-sizing: border-box; }++html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }++.ex { display: block; }++.ex a { color: var(--room-strong); }++.ex .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.ex-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.ex-narrow { max-width: 620px; }++.ex-page-pad { padding-top: clamp(28px, 6vw, 64px); }++.ex h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.ex-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.ex-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }++.ex-page-h h1, .ex-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }++.ex-lede, .ex-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.ex-fine, .ex-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.ex-section { margin-top: clamp(28px, 4vw, 44px); }++.ex-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.ex-count { color: var(--text-muted, #7386a3); font-size: .86rem; }++.ex-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }++.ex-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }++.ex-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }++.ex-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }++.ex-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }++.ex-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }++.ex-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.ex-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }++.ex-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }++.ex-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }++.ex-empty p { margin: 0; line-height: 1.5; }++.ex-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++++++.ex-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.ex-form { display: grid; gap: 14px; }++.ex-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }++.ex-field input, .ex-field select, .ex-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }++.ex-field input:focus, .ex-field select:focus, .ex-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }++.ex-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }++.ex-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.ex-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ex-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }++.ex-eps { display: grid; gap: 10px; margin: 12px 0 0; }++.ex-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.ex-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }++.ex-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++++++.ex-section-head, .ex-more-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.ex-prose { max-width: 720px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }++.ex-prose-list { margin: 0; padding-left: 20px; display: grid; gap: 6px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }++.ex-fieldset { margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: grid; gap: 10px; }++.ex-field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }++.ex-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }++.ex-filters a { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }++.ex-filters a[aria-current] { background: var(--room-fill); color: var(--room-on); border-color: transparent; }++.ex-inline { margin: 0; display: inline; }++.ex-ok, .ex-status-active, .ex-verified { color: var(--success-text, #22c55e); font-weight: 800; }++.ex-status-revoked, .ex-unverified { color: var(--text-muted, #7386a3); font-weight: 800; }++.ex-status, .ex-kind, .ex-mode, .ex-license, .ex-version { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); color: var(--text-secondary, #96a7c2); }++.ex-kind, .ex-mode { background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); }++++/* The motif: an item looping between two makers, a passport stamped "safe" */++.ex-motif { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 260; margin: 0 auto; color: var(--room-accent); }++.ex-motif-arrows { position: absolute; inset: 0; width: 100%; height: 100%; }++.ex-motif-arrows path { stroke: currentColor; stroke-width: 3; stroke-linecap: round; opacity: .7; }++html.ov-motion .ex-motif-arrows path { stroke-dasharray: 500; animation: ex-loop 2s cubic-bezier(.2,.7,.2,1) both; }++@keyframes ex-loop { from { stroke-dashoffset: 500; } }++.ex-motif-item { position: absolute; padding: 9px 14px; border-radius: 12px; font-size: .86rem; font-weight: 800; background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); color: var(--text-primary, #e6edf7); box-shadow: 0 8px 22px rgba(0, 0, 0, .22); }++.ex-motif-item-a { left: 4%; top: 18%; } .ex-motif-item-b { right: 4%; top: 18%; }++.ex-motif-passport { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; justify-items: center; padding: 10px 18px; border-radius: 14px; background: var(--room-fill); color: var(--room-on); rotate: -4deg; box-shadow: 0 10px 26px rgba(0, 0, 0, .28); }++.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .8; }++.ex-motif-passport b { font-size: 1.3rem; letter-spacing: .06em; text-transform: uppercase; }++html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }++@keyframes ex-stamp { from { opacity: 0; scale: 1.6; } }++++/* Scenes' demos */++.ex-demo { display: grid; gap: 8px; width: min(100%, 420px); margin: 0 auto; padding: 16px 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.ex-demo-head { font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.ex-demo-checks, .ex-demo-grants { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: .9rem; }++.ex-demo-grants b { display: inline-block; min-width: 64px; color: var(--text-muted, #7386a3); font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; }++.ex-demo-row { display: flex; justify-content: space-between; gap: 10px; font-size: .9rem; }++.ex-demo-stamp { justify-self: start; padding: 3px 10px; border-radius: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; color: var(--room-on); background: var(--room-fill); rotate: -3deg; }++.ex-demo-button { justify-self: start; padding: 7px 14px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--error, #ef4444) 50%, transparent); color: var(--error-text, #f87171); font-weight: 800; font-size: .86rem; }++++/* Listings */++.ex-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }++.ex-listing-card { display: grid; }++.ex-listing-link { display: grid; gap: 8px; height: 100%; padding: 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }++.ex-listing-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }++.ex-listing-top, .ex-listing-meta, .ex-listing-foot, .ex-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-muted, #7386a3); }++.ex-listing-title { font-size: 1.08rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }++.ex-listing-summary { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.5; }++.ex-listing-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; overflow-wrap: anywhere; }++.ex-creator, .ex-owner { font-weight: 700; color: var(--text-secondary, #96a7c2); }++++/* The passport: what a listing reads, writes and spends */++.ex-passport { position: relative; display: grid; gap: 12px; padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); border: 2px solid color-mix(in srgb, var(--room-accent) 40%, var(--border, #1f2d47)); }++.ex-passport-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }++.ex-passport-row { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.ex-passport-row dt, .ex-passport-row b { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ex-passport-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font: 600 .84rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }++.ex-passport-stamp { position: absolute; right: 16px; top: -12px; padding: 4px 12px; border-radius: 8px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; color: var(--room-on); background: var(--room-fill); rotate: 4deg; }++.ex-none { color: var(--text-muted, #7386a3); font-style: italic; }++.ex-manifest { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .84rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; }++++/* Checks and conformance tests */++.ex-checks, .ex-tests { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.ex-check, .ex-test { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .88rem; }++.ex-check-detail, .ex-test-detail { flex-basis: 100%; color: var(--text-muted, #7386a3); font-size: .82rem; }++.ex-test-name { font-weight: 700; } .ex-test-status { margin-left: auto; font-weight: 800; }++.ex-check-pass, .ex-test-pass { border-left: 4px solid var(--success, #22c55e); }++.ex-check-fail, .ex-test-fail { border-left: 4px solid var(--error, #ef4444); }++++/* Installs, offers, mine */++.ex-install-list, .ex-offer-list, .ex-offers-list, .ex-mine-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.ex-install, .ex-offer, .ex-mine-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.ex-install-revoked { opacity: .7; }++.ex-install-main, .ex-offer-body { display: grid; gap: 4px; min-width: 0; }++.ex-install-title { font-weight: 800; overflow-wrap: anywhere; }++.ex-install-meta, .ex-offer-head, .ex-offer-when, .ex-offer-from { font-size: .82rem; color: var(--text-muted, #7386a3); }++.ex-offer-give, .ex-wants { font-weight: 700; color: var(--text-secondary, #96a7c2); }++.ex-staff { color: var(--warning-text, #f59e0b); }++++@media (max-width: 759px) { .sc-room-pic .ex-motif-item, .sc-room-pic .ex-motif-passport { display: none; } }++@media (prefers-reduced-motion: reduce) { .ex-motif-arrows path, .ex-motif-passport { animation: none !important; } }++html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced] .ex-motif-passport { animation: none !important; }nnew file mode 100644@@@ -0,0 +1 @@++/* OpenVibe.Exchange: the room's own script is designed by the main session; the pages are complete without this file. */nnew file mode 100644@@@ -0,0 +1,92 @@++#!/usr/bin/env node++'use strict';++++/**++ * The first proof (plan §5.11): a licensed template listed, tried in the sandbox, installed with its passport's++ * grants, then revoked cleanly.++ *++ * node scripts/seed-proof.js++ *++ * It writes one CC0-1.0 Website template as a draft, runs the four conformance tests, lists it, takes it through a++ * sandbox trial as a second person, installs it with the passport snapshot and revokes that install. Every step++ * goes through the same store functions the API and the pages use; nothing is inserted behind their back. Re-running++ * is safe: an existing `starter-site` listing is left as it is.++ */++const { ids } = require('openvibe-contracts');++const { load } = require('../server/config');++const { openStore } = require('../server/db');++const { createStore, slugify } = require('../server/store');++const { createSearchIndex } = require('../server/search');++++const TITLE = 'Starter site template';++const SLUG = slugify(TITLE);++const LICENSE = 'CC0-1.0';++++const BODY = Object.freeze({++ title: TITLE,++ kind: 'template',++ mode: 'free',++ summary: 'A licensed, static starter site: one page, one stylesheet, no tracking and no build step.',++ description: 'A Website template for a maker\'s first page. Copy it into a Space, change the words, publish. The manifest names a static entry point and the passport asks only to read Search and write Space files; it declares no spend, because v1 has no money.',++ license: LICENSE,++ license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',++ version: '1.0.0',++ manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },++ passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },++});++++async function runSeed({ config, s, log = console } = {}) {++ if (!config) config = load();++ if (!s) s = await openStore(config, { log });++ const search = createSearchIndex({ config, s });++ const entries = createStore(s, { search, config });++++ const existing = await entries.bySlug(SLUG);++ if (existing) {++ log.log(`[seed-proof] ${SLUG} already exists (${existing.id}, ${existing.status}) — left as it is`);++ return { created: false, listing: entries.wireListing(existing), trial: null, install: null, revoked: null };++ }++++ const maker = ids.newId('user');++ const installer = ids.newId('user');++++ const draft = await entries.createListing({ owner: maker, creator: 'OpenVibe', body: BODY });++ if (!draft.ok) throw new Error(`listing was refused: ${draft.message}`);++ const listed = await entries.publish(draft.listing.slug, { owner: maker });++ if (!listed.ok) throw new Error(`listing was not published: ${listed.message}`);++++ const sandbox = await entries.sandbox(listed.listing.slug, { subject: installer });++ if (!sandbox.ok) throw new Error(`sandbox was refused: ${sandbox.message}`);++ if (sandbox.trial.status !== 'passed') throw new Error(`sandbox trial did not pass: ${JSON.stringify(sandbox.checks)}`);++++ const installed = await entries.install(listed.listing.slug, { subject: installer });++ if (!installed.ok) throw new Error(`install was refused: ${installed.message}`);++++ const revoked = await entries.revoke(installed.install.id, { subject: installer });++ if (!revoked.ok) throw new Error(`revoke was refused: ${revoked.message}`);++++ log.log(`[seed-proof] listed ${listed.listing.id} /listings/${listed.listing.slug} (${LICENSE}, free, version ${listed.listing.version})`);++ log.log(`[seed-proof] sandbox ${sandbox.trial.id}: ${sandbox.trial.status}; passport reads ${sandbox.trial.passport.reads.join(', ') || 'nothing'}, writes ${sandbox.trial.passport.writes.join(', ') || 'nothing'}, spends nothing`);++ log.log(`[seed-proof] installed ${installed.install.id} and revoked it (${revoked.install.status})`);++ return { created: true, listing: listed.listing, trial: sandbox.trial, install: installed.install, revoked: revoked.install };++}++++async function main() {++ const config = load();++ const s = await openStore(config, { log: console });++ try {++ const out = await runSeed({ config, s });++ process.exitCode = out && out.listing ? 0 : 1;++ } finally {++ await s.close();++ }++}++++if (require.main === module) {++ main().catch((err) => {++ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);++ process.exitCode = 1;++ });++}++++module.exports = { runSeed, SLUG, TITLE, LICENSE, BODY };nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++++/**++ * OpenVibe.Exchange — Express app factory; server/index.js listens, tests build their own instance with a temp++ * database and a mock Network.++ *++ * /, /listings, /listings/:slug, /submit, /sandbox/:id, /installs, /offers, /safety, /developers++ * the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /internal/events account export/deletion (loopback only)++ *++ * There are no jobs in v1: sandbox trials run on request and expire by timestamp. The Search relay is not wired.++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { assetVersion, send } = require('./render/layout');++const { raw } = require('./render/html');++++const accountDataLib = require('./identity/account-data');++const { createStore } = require('./store');++const { createSearchIndex } = require('./search');++const { 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 search = createSearchIndex({ config, s });++ const entries = createStore(s, { search, config });++++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own++ // client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });++++ const ctx = { config, s, keys, sso, principal, search, entries, accountData, accountSend, log };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'exchange', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'exchange', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'exchange', 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'"],++ 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-exchange', name: 'OpenVibe.Exchange', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ app.get('/api/ready', readiness.handler);++++ // ── Who is asking (verified offline; refreshed when expired) ──++ app.use(sso.middleware());++++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', sso.routes());++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'exchange', service: 'exchange', host: 'openvibe.exchange', name: 'OpenVibe.Exchange', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only) ──────++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.++ // nginx answers 404 for /internal/.++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_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.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => send(res, 404, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',++ body: raw(require('./views').notFound()),++ }));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Exchange]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's or app'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.Exchange] 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.Exchange] 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.Exchange] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,260 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `exchange`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256++ * (?silent=1 adds prompt=none; ?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest++ *++ * This is Meme's SSO module with the client id and cookie names changed. The person's Network access token lives++ * in an httpOnly cookie (exchange_at) no script can read; the refresh token (exchange_rt) is httpOnly too. Neither++ * is ever stored server-side, rendered or logged.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'exchange_at';++const REFRESH_COOKIE = 'exchange_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'exchange_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++function ownOrigins(config) {++ const out = new Set();++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }++ }++ return out;++}++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next, config) {++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null;++ try { u = new URL(next); } catch { return '/'; }++ if (!ownOrigins(config).has(u.origin)) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ /**++ * An Exchange session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ res.set('Clear-Site-Data', '"cache"');++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Exchange] sign-in exchange failed:', err.status || '', err.error || '');++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');++ }++ });++++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET';++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Exchange?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.set('Clear-Site-Data', '"cache"');++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next, config));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++++/**++ * OpenVibe.Exchange configuration. Every value comes from the environment (production:++ * /etc/openvibe/exchange.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);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5150);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.exchange' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'exchange',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.EXCHANGE_DRILL === '1' || env.EXCHANGE_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.EXCHANGE_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.EXCHANGE_LIMITS_HOUR, 3000)),++ },++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.EXCHANGE_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:exchange:' },++++ // 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.EXCHANGE_AUDIENCE || 'openvibe.exchange',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'exchange',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).++ events: {++ secrets: String(env.EXCHANGE_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.EXCHANGE_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.EXCHANGE_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.EXCHANGE_EVENTS_INTERVAL_MS, 5000)),++ scope: env.EXCHANGE_EVENTS_SCOPE || 'events.event.publish',++ },++++ // The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and++ // is required before an install; a person gets at most this many trials a day.++ sandbox: {++ ttlMinutes: Math.min(240, Math.max(5, int(env.EXCHANGE_SANDBOX_TTL_MINUTES, 60))),++ perDay: Math.min(100, Math.max(1, int(env.EXCHANGE_SANDBOX_PER_DAY, 20))),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/**++ * OpenVibe.Exchange's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret. A listing's manifest is public metadata, never a credential: an entry++ * that looks like a secret is refused before it is stored, and the passport is what the listing may read, write,++ * spend or contact — a public declaration, not a key.++ */++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.Exchange] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'exchange', 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: 'exchange-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'exchange', 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,254 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Exchange's JSON API.++ *++ * GET /listings?limit=&cursor=&mode=&kind=&q= public listings, newest first, cursor-paged++ * GET /listings/:slug one listing with its passport, tests and licence++ * GET /listings/:slug/passport the exact passport and manifest an installer accepts++ * GET /listings/:slug/tests the conformance tests for this version++ * GET /listings/:slug/offers the open free claims / swap offers (owner or staff)++ * GET /me/listings the caller's listings, all statuses++ * POST /listings create a draft (signed in, same-origin)++ * POST /listings/:slug/list publish after the conformance tests passed (owner)++ * POST /listings/:slug/close close (given / swapped / withdrawn) (owner)++ * POST /listings/:slug/sandbox run a manifest dry-run trial (signed in)++ * GET /sandbox/:id one trial of yours++ * POST /listings/:slug/install install with the passport snapshot (signed in; trial required)++ * GET /installs the caller's installations++ * POST /installs/:id/revoke revoke one installation (signed in)++ * POST /listings/:slug/offers a free claim or a swap offer (signed in)++ * GET /offers offers the caller sent and received++ * POST /offers/:id/accept|decline|withdraw decide an offer++ * POST /listings/:slug/hide staff: hide a listing++ *++ * Errors are application/problem+json with a `code`. A person is the exchange_at session cookie or a Network user++ * token as a Bearer; a session write must come from openvibe.exchange itself (403 exchange.cross_site). There is no++ * money: a listing is free or a swap, and a passport may not declare a spend.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { isStaff } = require('../identity/staff');++const { KINDS, MODES } = require('../store');++++function createApi(ctx) {++ const { config, s, principal, entries } = ctx;++ const r = asyncRouter();++++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and++ // hour budget from config, and an app or anonymous caller is left to the address limiter.++ const buckets = new Map();++ function windowCount(key, windowMs, limit) {++ const now = Date.now();++ let b = buckets.get(key);++ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }++ b.n += 1;++ return b.n <= limit;++ }++ r.use((req, res, next) => {++ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {++ const who = `user:${req.principal.subject}`;++ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);++ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);++ if (!okMinute || !okHour) return problem(res, 429, 'exchange.rate_limited', 'too many requests from this account; retry shortly');++ if (buckets.size > 20_000) {++ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);++ }++ }++ return next();++ });++++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });++ const invalid = (res, detail) => problem(res, 400, 'exchange.invalid', detail);++++ function person(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') { problem(res, 401, 'exchange.sign_in_required', 'Sign in at openvibe.exchange, or send a person\'s Network token as a Bearer.'); return null; }++ if (p.kind !== 'user') { problem(res, 403, 'exchange.person_required', 'This route is for a person, not an app.'); return null; }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ problem(res, 403, 'exchange.cross_site', 'A signed-in request that changes something must come from openvibe.exchange itself.');++ return null;++ }++ return p;++ }++ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);++ const requireStaff = (req, res, next) => {++ if (!person(req, res)) return undefined;++ if (!isStaff(req.principal)) return problem(res, 403, 'exchange.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');++ return next();++ };++++ const creatorOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.displayName) || req.principal.username || 'a maker';++++ /** A listing the caller may see: public, or their own, or staff. */++ async function visible(req, res, slug) {++ const row = await entries.bySlug(String(slug || ''));++ if (!row) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }++ const mine = req.principal.kind === 'user' && row.owner === req.principal.subject;++ const staff = isStaff(req.principal);++ if (row.status !== 'listed' && !mine && !staff) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }++ return { row, mine, staff };++ }++++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));++++ // ── Public reads ────────────────────────────────────────++ r.get('/listings', async (req, res) => {++ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');++ const mode = typeof req.query.mode === 'string' && req.query.mode ? req.query.mode : null;++ if (mode && !MODES.includes(mode)) return invalid(res, `mode must be one of ${MODES.join(', ')}`);++ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;++ if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);++ const out = await entries.list({++ limit, mode, kind,++ q: typeof req.query.q === 'string' ? req.query.q : null,++ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,++ });++ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out');++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ count: out.listings.length, listings: out.listings, next_cursor: out.next_cursor });++ });++++ r.get('/listings/:slug', async (req, res) => {++ const found = await visible(req, res, req.params.slug);++ if (!found) return undefined;++ const detail = await entries.detail(found.row.slug);++ if (!detail) return problem(res, 404, 'exchange.not_found', 'No such listing.');++ const out = { ...detail.listing, mine: found.mine, staff: found.staff };++ if (!found.mine && !found.staff) delete out.owner;++ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');++ return res.json(out);++ });++++ r.get('/listings/:slug/passport', async (req, res) => {++ const found = await visible(req, res, req.params.slug);++ if (!found) return undefined;++ const out = {++ slug: found.row.slug,++ version: found.row.version,++ mode: found.row.mode,++ license: found.row.license,++ creator: found.row.creator,++ manifest: entries.wireListing(found.row).manifest,++ passport: entries.wirePassport(found.row.passport),++ tests: await entries.testsFor(found.row.id, found.row.version),++ };++ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');++ return res.json(out);++ });++++ r.get('/listings/:slug/tests', async (req, res) => {++ const found = await visible(req, res, req.params.slug);++ if (!found) return undefined;++ const rows = await entries.testsFor(found.row.id, found.row.version);++ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');++ return res.json({ version: found.row.version, count: rows.length, verified: rows.length > 0 && rows.every((t) => t.status === 'passed'), tests: rows });++ });++++ r.get('/listings/:slug/offers', requirePerson, async (req, res) => {++ const found = await visible(req, res, req.params.slug);++ if (!found) return undefined;++ if (!found.mine && !found.staff) return problem(res, 403, 'exchange.owner_only', 'Only the listing owner or staff can read its offers.');++ return res.json({ count: (await entries.offersForListing(found.row.id)).length, offers: await entries.offersForListing(found.row.id) });++ });++++ r.get('/me/listings', requirePerson, async (req, res) => {++ const listings = await entries.listMine(req.principal.subject);++ return res.json({ count: listings.length, listings });++ });++++ // ── Listings (a person) ─────────────────────────────────++ r.post('/listings', requirePerson, async (req, res) => {++ const out = await entries.createListing({ owner: req.principal.subject, creator: creatorOf(req), body: req.body || {} });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'invalid listing');++ res.set('Location', `/api/v1/listings/${out.listing.slug}`);++ return res.status(201).json(out.listing);++ });++++ r.post('/listings/:slug/list', requirePerson, async (req, res) => {++ const out = await entries.publish(String(req.params.slug || ''), { owner: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot list');++ return res.json(out.listing);++ });++++ r.post('/listings/:slug/close', requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const out = await entries.close(String(req.params.slug || ''), { owner: req.principal.subject, reason: b.reason });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot close');++ return res.json(out.listing);++ });++++ r.post('/listings/:slug/hide', requireStaff, async (req, res) => {++ const out = await entries.hide(String(req.params.slug || ''), { changedBy: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot hide');++ return res.json(out.listing);++ });++++ // ── Sandbox ─────────────────────────────────────────────++ r.post('/listings/:slug/sandbox', requirePerson, async (req, res) => {++ const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot start the sandbox');++ res.set('Location', `/api/v1/sandbox/${out.trial.id}`);++ return res.status(201).json({ trial: out.trial, checks: out.checks });++ });++++ r.get('/sandbox/:id', requirePerson, async (req, res) => {++ const out = await entries.trialById(String(req.params.id || ''), { subject: req.principal.subject });++ if (!out) return problem(res, 404, 'exchange.trial_not_found', 'No such sandbox trial of yours.');++ return res.json(out.trial);++ });++++ // ── Installs ────────────────────────────────────────────++ r.post('/listings/:slug/install', requirePerson, async (req, res) => {++ const out = await entries.install(String(req.params.slug || ''), { subject: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot install');++ if (!out.unchanged) res.set('Location', `/api/v1/installs`);++ return res.status(out.unchanged ? 200 : 201).json(out.install);++ });++++ r.get('/installs', requirePerson, async (req, res) => {++ const installs = await entries.installsFor(req.principal.subject);++ return res.json({ count: installs.length, installs });++ });++++ r.post('/installs/:id/revoke', requirePerson, async (req, res) => {++ const out = await entries.revoke(String(req.params.id || ''), { subject: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot revoke');++ return res.json(out.install);++ });++++ // ── Free claims and swap offers ─────────────────────────++ r.post('/listings/:slug/offers', requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const out = await entries.offer(String(req.params.slug || ''), {++ subject: req.principal.subject, fromName: creatorOf(req), body: b.body, give: b.give,++ });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot send the offer');++ res.set('Location', `/api/v1/offers`);++ return res.status(201).json(out.offer);++ });++++ r.get('/offers', requirePerson, async (req, res) => {++ const out = await entries.offersFor(req.principal.subject);++ return res.json({ sent: out.sent.length, received: out.received.length, ...out });++ });++++ for (const action of ['accept', 'decline', 'withdraw']) {++ r.post(`/offers/:id/${action}`, requirePerson, async (req, res) => {++ const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.principal.subject, action });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || `cannot ${action} the offer`);++ return res.json({ offer: out.offer, ...(out.closed ? { listing: out.closed } : {}) });++ });++ }++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.exchange: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public listings are++ * for search engines and AI crawlers; sign-in, the API, the internal route and a non-listed listing are not. A++ * sitemap also lists every public listing page (/listings/<slug>).++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Exchange';++const DESCRIPTION = 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation. Free and swap only in v1.';++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/sandbox/', '/installs', '/offers', '/submit'];++++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++function siteUpdated() {++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }++}++++function homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'ShoppingApplication', keywords: 'openvibe exchange, marketplace, free and swap, permission passport, sandbox, install, revoke, templates, themes, game assets' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function publicPages() {++ return [++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Trade what people make: free and swap listings, each with a permission passport, a sandbox and one-tap revocation.' },++ { path: '/listings', changefreq: 'daily', priority: 0.9, title: 'All listings', text: 'Every public free and swap listing: agents, MCP tools, templates, themes, game assets and creator services.' },++ { path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Free and swap safety rules', text: 'No money, no prohibited items, read the passport, meet safely and revoke any time.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Exchange API', text: 'The JSON API, the passport, the sandbox, installs and the Search document.' },++ ];++}++++function createDiscoveryRoutes(ctx) {++ const { config, s } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++ const pages = publicPages();++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates (Website, Report, Space), design themes and kits, game assets and creator services. Every public listing shows a permission passport (exactly what it can read, write, spend or contact), its licence, the conformance tests for that version, and a sandbox. A passed sandbox trial is required before an install; a revoke removes the installation in one tap. v1 is free and swap only: there is no money and a passport may not declare a spend.',++ details: 'GET /api/v1/listings lists public listings; GET /api/v1/listings/:slug adds the passport, manifest, licence, tests and counters; GET /api/v1/listings/:slug/passport is the exact passport an installer accepts. The sandbox is a manifest dry run in v1: Exchange does not execute a listing\'s code. Every public listing is one search.index-document@1 (owner exchange, type listing) queued in exchange_outbox.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Exchange', url: abs('/'), note: 'free and swap listings' },++ { title: 'All listings', url: abs('/listings') },++ { title: 'Safety rules', url: abs('/safety') },++ { title: 'The API', url: abs('/developers') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Public listings (JSON)', url: abs('/api/v1/listings') },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'Full text for language models', url: abs('/llms-full.txt') },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ { title: 'Elsewhere', links: [++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, staff roles and the grants a passport names' },++ { title: 'OpenVibe.Run', url: 'https://openvibe.run', note: 'the sandbox that will execute a listing\'s tests' },++ { title: 'OpenVibe.Search', url: 'https://openvibe.search', note: 'where public listings are indexed' },++ { title: 'OpenVibe.Rent', url: 'https://openvibe.rent', note: 'the listings engine and local safety rules Exchange borrows' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', async (_req, res) => {++ const rows = await s.db.many("SELECT slug, title, summary, kind, mode, license FROM exchange_listings WHERE status = 'listed' ORDER BY listed_at DESC LIMIT 2000");++ const listings = rows.map((e) => ({ url: `/listings/${e.slug}`, title: e.title, text: [e.kind, e.mode, e.license, e.summary].filter(Boolean).join(' · ') }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Exchange: the home, all listings, the safety rules, the API and every public listing.',++ base: site,++ maxBytes: 512 * 1024,++ sections: [++ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },++ { title: 'Listings', pages: listings },++ ],++ }));++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ const lastmod = siteUpdated();++ const rows = await s.db.many("SELECT slug, updated_at FROM exchange_listings WHERE status = 'listed' ORDER BY slug LIMIT 5000");++ const urls = [++ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),++ ...rows.map((e) => ({ loc: abs(`/listings/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),++ ];++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,277 @@++'use strict';++++/**++ * Exchange's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real++ * link. Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is++ * `private, no-store` and a page that needs an account is not cached.++ *++ * / the hero, the three scenes, fresh listings++ * /listings browse and filter (free/swap, kind, q)++ * /listings/:slug passport, tests, sandbox, install/revoke, free claim or swap offer++ * /submit create a listing (draft; POST /submit)++ * /sandbox/:id one sandbox trial and its checks++ * /installs the caller's installations and one-tap revoke++ * /offers free claims and swap offers, sent and received++ * /safety the free and swap rules++ * /developers the API++ * 404 the not-found page++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { sameOrigin } = require('./principal');++const { html, raw } = require('../render/html');++const { send } = require('../render/layout');++const { isStaff } = require('../identity/staff');++const views = require('../views');++++const PUBLIC_CACHE = 'public, max-age=60';++++function createPageRoutes(ctx) {++ const { config, entries } = ctx;++ const r = asyncRouter();++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const staffViewer = (req) => isStaff(req.viewer);++ const creatorOf = (req) => (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'a maker';++ const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>That came from somewhere else</h1><p class="ex-muted">Use the form on <a href="/">OpenVibe.Exchange</a>.</p></div></div>`) }, 403);++ const problemOf = (req) => (typeof req.query.problem === 'string' && req.query.problem ? req.query.problem.slice(0, 300) : null);++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', async (req, res) => {++ const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: null,++ description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ listings: list.listings, counts })),++ });++ });++++ // ── Browse ───────────────────────────────────────────────++ r.get('/listings', async (req, res) => {++ const mode = typeof req.query.mode === 'string' && ['free', 'swap'].includes(req.query.mode) ? req.query.mode : null;++ const kind = typeof req.query.kind === 'string' && entries.KINDS.includes(req.query.kind) ? req.query.kind : null;++ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';++ const out = await entries.list({++ limit: 24, mode, kind, q,++ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,++ });++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'All listings',++ description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',++ body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),++ });++ });++++ // ── One listing ──────────────────────────────────────────++ async function renderListing(req, res, { slug, status = 200, problem = null } = {}) {++ const detail = await entries.detail(String(slug || ''));++ if (!detail) return notFound(req, res);++ const row = detail.row;++ const mine = Boolean(signedIn(req) && row.owner === req.viewer.subject);++ const staff = staffViewer(req);++ if (row.status !== 'listed' && !mine && !staff) return notFound(req, res);++ const listing = detail.listing;++ const subject = signedIn(req) ? req.viewer.subject : null;++ const [activeInstall, trialPassed, offers] = await Promise.all([++ subject ? entries.activeInstall(row.id, subject) : null,++ subject ? entries.hasPassedTrial(row.id, subject, row.version) : false,++ (mine || staff) ? entries.offersForListing(row.id) : [],++ ]);++ const notice = req.query.submitted === '1' ? 'Draft saved. Run the conformance tests below; when they pass you can list it publicly.'++ : req.query.offered === '1' ? 'Your offer is sent. The maker decides.'++ : req.query.closed === '1' ? 'The listing is closed.' : null;++ const jsonLd = seo.jsonLd.webPage({++ name: listing.title,++ url: `${base()}/listings/${listing.slug}`,++ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),++ siteUrl: base(),++ });++ return page(req, res, {++ index: row.status === 'listed',++ cache: row.status === 'listed' ? PUBLIC_CACHE : null,++ title: listing.title,++ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),++ jsonLd: [jsonLd],++ body: raw(views.listingPage({++ listing, tests: detail.listing.tests && detail.listing.tests.rows ? detail.listing.tests.rows : [],++ mine, staff, signedIn: Boolean(subject), activeInstall, trialPassed,++ offers, received: mine,++ problem: problem || problemOf(req), notice: req.query.revoked === '1' ? 'Revoked.' : notice,++ })),++ }, status);++ }++++ r.get('/listings/:slug', (req, res) => renderListing(req, res, { slug: req.params.slug }));++++ // ── Submit (create a draft) ──────────────────────────────++ function formValues(b = {}) {++ return {++ title: b.title || '', kind: b.kind || 'template', mode: b.mode || 'free', summary: b.summary || '',++ description: b.description || '', wants: b.wants || '', license: b.license || 'CC0-1.0',++ license_url: b.license_url || '', version: b.version || '1.0.0',++ manifest_name: b.manifest_name || '', manifest_entry: b.manifest_entry || '', manifest_runtime: b.manifest_runtime || '',++ passport_reads: b.passport_reads || '', passport_writes: b.passport_writes || '', passport_contacts: b.passport_contacts || '',++ };++ }++ const splitList = (v) => String(v == null ? '' : v).split(/[\n,]+/).map((x) => x.trim()).filter(Boolean);++++ r.get('/submit', async (req, res) => {++ const mine = signedIn(req) ? await entries.listMine(req.viewer.subject) : [];++ return page(req, res, {++ index: false,++ title: 'List something',++ description: 'List an agent, MCP tool, template, theme, game asset or creator service on OpenVibe.Exchange. Free or swap only in v1.',++ body: raw(views.submitForm({ values: {}, mine })),++ });++ });++++ r.post('/submit', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'List something', body: raw(views.signIn({ title: 'List something', text: 'A listing belongs to your OpenVibe account, so you can manage it later.', next: '/submit' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = req.body || {};++ const body = {++ title: b.title, kind: b.kind, mode: b.mode, summary: b.summary, description: b.description, wants: b.wants,++ license: b.license, license_url: b.license_url, version: b.version,++ manifest: { name: b.manifest_name, entry: b.manifest_entry, runtime: b.manifest_runtime },++ passport: {++ reads: splitList(b.passport_reads), writes: splitList(b.passport_writes), spends: [], contacts: splitList(b.passport_contacts),++ },++ };++ const out = await entries.createListing({ owner: req.viewer.subject, creator: creatorOf(req), body });++ if (!out.ok) {++ const mine = await entries.listMine(req.viewer.subject);++ return page(req, res, {++ title: 'List something', body: raw(views.submitForm({ values: formValues(b), mine, problem: out.message || 'That listing could not be saved.' })),++ }, out.status || 400);++ }++ return res.redirect(303, `/listings/${encodeURIComponent(out.listing.slug)}?submitted=1`);++ });++++ // ── Owner actions ────────────────────────────────────────++ r.post('/listings/:slug/list', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.publish(String(req.params.slug || ''), { owner: req.viewer.subject });++ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?listed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot list it')}`}`);++ });++++ r.post('/listings/:slug/close', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.close(String(req.params.slug || ''), { owner: req.viewer.subject, reason: req.body && req.body.reason });++ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?closed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot close it')}`}`);++ });++++ // ── Sandbox ──────────────────────────────────────────────++ r.post('/listings/:slug/sandbox', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.viewer.subject });++ if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot start the sandbox')}`);++ return res.redirect(303, `/sandbox/${encodeURIComponent(out.trial.id)}`);++ });++++ r.get('/sandbox/:id', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sandbox trial', body: raw(views.signIn({ title: 'Sandbox trial', text: 'A trial belongs to your account; sign in to see it.', next: `/sandbox/${req.params.id}` })) }, 401);++ const out = await entries.trialById(String(req.params.id || ''), { subject: req.viewer.subject });++ if (!out) return notFound(req, res);++ return page(req, res, {++ index: false,++ title: 'Sandbox trial',++ description: 'A manifest and passport dry run on OpenVibe.Exchange.',++ body: raw(views.sandboxPage({ trial: out.trial, listing: out.listing })),++ });++ });++++ // ── Installs ─────────────────────────────────────────────++ r.post('/listings/:slug/install', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.install(String(req.params.slug || ''), { subject: req.viewer.subject });++ if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot install')}`);++ return res.redirect(303, `/installs?installed=1`);++ });++++ r.get('/installs', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Your installations', body: raw(views.signIn({ title: 'Your installations', text: 'An installation belongs to your account; sign in to manage it.', next: '/installs' })) }, 401);++ const installs = await entries.installsFor(req.viewer.subject);++ return page(req, res, {++ index: false,++ title: 'Your installations',++ description: 'Everything you have installed on OpenVibe.Exchange, with its passport and a one-tap revoke.',++ body: raw(views.installsPage({ installs })),++ });++ });++++ r.post('/installs/:id/revoke', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Finstalls');++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.revoke(String(req.params.id || ''), { subject: req.viewer.subject });++ return res.redirect(303, `/installs?${out.ok ? 'revoked=1' : `problem=${encodeURIComponent(out.message || 'Cannot revoke it')}`}`);++ });++++ // ── Free claims and swap offers ──────────────────────────++ r.get('/offers', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Your offers', body: raw(views.signIn({ title: 'Your offers', text: 'Offers belong to your account; sign in to see them.', next: '/offers' })) }, 401);++ const out = await entries.offersFor(req.viewer.subject);++ return page(req, res, {++ index: false,++ title: 'Your offers',++ description: 'The free claims and swap offers you sent and received on OpenVibe.Exchange.',++ body: raw(views.offersPage(out)),++ });++ });++++ r.post('/listings/:slug/offers', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.offer(String(req.params.slug || ''), {++ subject: req.viewer.subject, fromName: creatorOf(req),++ body: req.body && req.body.body, give: req.body && req.body.give,++ });++ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?${out.ok ? 'offered=1' : `problem=${encodeURIComponent(out.message || 'Cannot send the offer')}`}`);++ });++++ for (const action of ['accept', 'decline', 'withdraw']) {++ r.post(`/offers/:id/${action}`, async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Foffers');++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.viewer.subject, action });++ return res.redirect(303, `/offers?${out.ok ? `${action}ed=1` : `problem=${encodeURIComponent(out.message || `Cannot ${action} the offer`)}`}`);++ });++ }++++ // ── Static pages ─────────────────────────────────────────++ r.get('/safety', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Free and swap safety',++ description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',++ body: raw(views.safetyPage()),++ }));++++ r.get('/developers', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'The OpenVibe.Exchange API',++ description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],++ body: raw(views.developersPage()),++ }));++++ // ── Discovery ───────────────────────────────────────────++ r.use(createDiscoveryRoutes(ctx));++++ function notFound(req, res) {++ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);++ }++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,103 @@++'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 (exchange_at).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience openvibe.exchange. No Exchange v1 route needs one: the++ * sandbox and installs are acts of a person. The shape is kept so a developer-app listing route can be added++ * in one place once Network grants the capabilities named in README.++ * { 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 install, revoke or offer.++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++// Exchange's v1 routes are a person acting for themself, a public read, and staff hiding a listing. No service++// capability is required by this service. The grants it will ask Network for are external: see README.++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.Exchange's capabilities`);++ return function guard(req, res, next) {++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.exchange, 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, 'exchange.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.exchange itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'exchange.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });++ };++ }++++ return { middleware, resolve, requireCapability: require_ };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * Account export and deletion → Exchange (ADR-033; openvibe-sdk/account-data). The rows a person owns are their++ * listings, their installations, their sandbox trials and the offers they made. A listing is anonymised, then++ * closed (or deleted if it was still a draft) in extraErase, and its Search document is tombstoned in the same++ * deletion transaction; nobody else's offers or installs are touched.++ *++ * The database stores bare usr_ ids in the subject columns. A listing's public creator name is the maker's own++ * display name; the passport, manifest, licence and tests are public metadata, never a credential.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++const { ids } = require('openvibe-contracts');++++const LISTING_COLUMNS = [++ 'id', 'slug', 'title', 'kind', 'mode', 'summary', 'description', 'wants', 'license', 'license_url', 'version',++ 'creator', 'status', 'closed_reason', 'revision', 'listed_at', 'closed_at', 'created_at', 'updated_at',++];++++const TABLES = [++ { table: 'exchange_listings', subject: 'owner', value: (usr) => usr, file: 'listings.json', columns: LISTING_COLUMNS, erase: { anonymize: { creator: 'a deleted maker' } } },++ { table: 'exchange_installs', subject: 'subject', value: (usr) => usr, file: 'installs.json', erase: 'delete' },++ { table: 'exchange_sandbox_trials', subject: 'subject', value: (usr) => usr, file: 'sandbox-trials.json', erase: 'delete' },++ { table: 'exchange_offers', subject: 'from_subject', value: (usr) => usr, file: 'offers.json', erase: 'delete' },++];++++function create({ db, log = console, now = () => Date.now() } = {}) {++ async function extraErase(t, subjects, { erased, retained, add }) {++ // The table map anonymised every listing whose owner is one of the subjects; close the public ones and++ // tombstone their Search documents, delete the drafts outright.++ const rows = await t.many('SELECT id, slug, status, mode, revision FROM exchange_listings WHERE owner IS NULL');++ let closed = 0;++ let deleted = 0;++ for (const row of rows) {++ if (row.status === 'draft') {++ await t.exec('DELETE FROM exchange_listings WHERE id = $1', [row.id]);++ deleted++;++ continue;++ }++ // Everything a person wrote is replaced, not just the owner link. A swap still needs a wants value++ // (the table's mode rule), so it gets a neutral one rather than the maker's words.++ const revision = Math.max(1, Number(row.revision) || 1) + (row.status === 'closed' ? 0 : 1);++ await t.exec(++ `UPDATE exchange_listings++ SET title = 'A deleted listing', summary = 'Removed with its maker''s account.', description = NULL,++ wants = CASE WHEN mode = 'swap' THEN 'A deleted swap' ELSE NULL END,++ status = 'closed', closed_reason = COALESCE(closed_reason, 'withdrawn'), closed_at = COALESCE(closed_at, now()),++ revision = $2, updated_at = now()++ WHERE id = $1`,++ [row.id, revision],++ );++ if (row.status === 'closed') continue;++ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);++ await t.exec("INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, 'closed', 'account deletion', 'account-deletion')", [row.id, row.status]);++ const eventId = ids.newId('event', now());++ await t.exec(++ `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ($1, 'exchange.index_document.deleted', $2::jsonb, $3::timestamptz, $3::timestamptz)`,++ [eventId, JSON.stringify({ owner: 'exchange', type: 'listing', id: row.id, revision, deleted: true }), new Date(now()).toISOString()],++ );++ closed++;++ }++ if (closed) add(retained, 'exchange_listings_closed', closed);++ if (deleted) add(erased, 'exchange_listings_drafts', deleted);++ }++++ return createAccountData({++ db,++ service: 'exchange',++ tables: TABLES,++ extraErase,++ note: 'Listings are anonymised and closed (or deleted when still drafts); installations, sandbox trials and offers are erased.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * Who is staff at OpenVibe.Exchange: the Network role claim on the person's session (server/auth/sso.js puts++ * `role` on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's++ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.++ *++ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.++ */++const { staff } = require('openvibe-contracts');++++const STAFF_MIN = 'global_mod';++const STAFF_ROLES = ['global_mod', 'admin', 'owner'];++++/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */++function isStaff(who) {++ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;++ if (typeof who.role !== 'string' || !who.role) return false;++ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }++}++++module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * OpenVibe.Exchange — process entry. `node server/index.js`++ * Listens on PORT (5150) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then 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.Exchange', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => 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.Exchange] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded++ // only because the first JWKS fetch had not happened.++ ctx.keys.ensure().catch(() => {});++ });++ server.keepAliveTimeout = 65_000;++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);++ ctx.telemetryTick.unref();++ ctx.keys.client.start();++++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the++ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.++ const subs = config.drill ? null : startSubscriptions({++ eventsUrl: config.events.url,++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,++ secret: config.events.secrets[0] || '',++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ log: ctx.log || console,++ });++ const extra = [() => { if (subs) subs.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Exchange] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,++ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has++ * signed in to yet is not degraded only because the first fetch had not happened.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'exchange',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * This is Meme's layout with Exchange's name, navigation, room accent and assets.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++const { render, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Exchange';++const TAGLINE = 'Trade what people make.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Listings', href: '/listings' },++ { label: 'Installations', href: '/installs' },++ { label: 'Sell or swap', href: '/submit' },++ { label: 'Developers', href: '/developers' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++/**++ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles++ */++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const loginNext = encodeURIComponent(pathNow);++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const nav = {++ service: 'exchange',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ const footer = { service: 'exchange', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${esc(c.label)}</a>` : esc(c.label)}`).join('')}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'exchange' })}++<link rel="stylesheet" href="${ovServe.url('kit.css')}">++<link rel="stylesheet" href="${ovServe.url('room.css')}">++<link rel="stylesheet" href="${ovServe.url('showcase.css')}">++<link rel="stylesheet" href="${asset('css/exchange.css')}">++<script src="${ovServe.url('motion.js')}" defer></script>++<script src="${asset('js/exchange.js')}" defer></script>++${render(scripts)}++<meta name="ov-boost" content="exchange@${esc(RELEASE)}">++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>++<main id="main" class="ex ov-room" style="${rooms.style('exchange')}">++${crumbs}++${render(o.body)}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++++/**++ * Free and swap safety rules, shared by the form and the API so a thing cannot be listed through one and refused++ * by the other.++ *++ * v1 has no money anywhere: a listing is free or a swap, the passport's `spends` is empty, and a title, body or++ * offer that asks for payment is refused. A short prohibited-items list covers what a marketplace must never++ * carry: weapons, illegal drugs, malware and stolen credentials, sexual services, forged documents, live animals.++ * This is a fixed list, not a classifier: it catches the plain cases and staff can hide anything else.++ *++ * `SAFETY` is the note every listing page carries word for word (the plan's "Rent's listing engine and safety++ * rules"): see the person first where the swap is physical, never send money, keep the passport visible.++ */++++const SAFETY = 'OpenVibe.Exchange v1 has no payments: a listing is free or a swap, and nobody should send money. Meet in a public place for a local swap, keep the conversation on the listing, and check the permission passport before you install anything.';++++const PROHIBITED = Object.freeze([++ { code: 'weapons', label: 'weapons or ammunition', re: /\b(?:firearms?|guns?|shotguns?|rifles?|pistols?|ammunition|explosives?|silencers?|untraceable weapons?)\b/i },++ { code: 'drugs', label: 'illegal drugs', re: /\b(?:cocaine|heroin|fentanyl|methamphetamine|mdma|ecstasy|illegal drugs?)\b/i },++ { code: 'malware', label: 'malware or stolen access', re: /\b(?:malware|ransomware|keyloggers?|spyware|botnets?|credential dumps?|stolen (?:accounts?|credentials?|data|access))\b/i },++ { code: 'sexual', label: 'sexual services or content', re: /\b(?:csam|child sexual|sexual services?|escort services?|pornography)\b/i },++ { code: 'forged', label: 'forged or counterfeit documents', re: /\b(?:fake ids?|forged (?:ids?|passports?|documents?)|counterfeit (?:money|currency|documents?))\b/i },++ { code: 'animals', label: 'live animals', re: /\b(?:live animals?|pupp(?:y|ies)|kittens?)\b/i },++ { code: 'money', label: 'money or a payment', re: /\b(?:send cash|pay in cash|cash only|wire transfer|bank transfer|western union|moneygram|venmo|paypal|zelle|gift cards?|bitcoin|cryptocurrency|crypto payment|usd|eur|gbp)\b/i },++]);++++const MONEY_AMOUNT = /(?:[£$€¥]\s?\d|\b\d+(?:[.,]\d{1,2})?\s?(?:usd|eur|gbp|vibes?|dollars?|euros?|pounds?)\b)/i;++++/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */++function cleanText(value, max) {++ const s = String(value == null ? '' : value)++ .replace(/\r\n?/g, '\n')++ // eslint-disable-next-line no-control-regex++ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')++ .trim();++ return max != null ? s.slice(0, max) : s;++}++++/** The first prohibited rule a set of public texts trips, or null. */++function findProhibited(...texts) {++ const haystack = texts.filter(Boolean).join('\n');++ if (!haystack) return null;++ for (const rule of PROHIBITED) if (rule.re.test(haystack)) return rule;++ if (MONEY_AMOUNT.test(haystack)) return PROHIBITED.find((r) => r.code === 'money');++ return null;++}++++/** A refusal shape the store returns and the API/page layers turn into a problem or a form message. */++function refusals(...texts) {++ const hit = findProhibited(...texts);++ if (!hit) return null;++ return { status: 400, code: 'exchange.prohibited', message: `A listing or offer cannot carry ${hit.label} on OpenVibe.Exchange.`, field: 'text' };++}++++module.exports = { SAFETY, PROHIBITED, findProhibited, refusals, cleanText, MONEY_AMOUNT };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++++/**++ * OpenVibe.Exchange's Search Fabric feed. Every public listing is one `search.index-document@1` (owner `exchange`,++ * type `listing`, page `/listings/<slug>`), written to `exchange_outbox` in the same transaction as the listing++ * change; a listing that is not `listed` (draft, hidden, closed) writes a tombstone, which wins over any older++ * revision. The document carries the maker's copy, the passport's declared permissions and the licence, so Search++ * can filter free/swap and kinds without reading the listing page.++ *++ * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer is++ * configured for `exchange` (README, "Grants it will need"). The relay is deliberately not part of v1.++ */++const { ids } = require('openvibe-contracts');++++const OWNER = 'exchange';++const TYPE = 'listing';++const EVENT_UPSERTED = 'exchange.index_document.upserted';++const EVENT_DELETED = 'exchange.index_document.deleted';++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const pagePath = (listing) => `/listings/${listing.slug}`;++++function documentOf(listing, { baseUrl = 'https://openvibe.exchange' } = {}) {++ const revision = Math.max(1, Number(listing && listing.revision) || 1);++ if (!listing || listing.status !== 'listed') return { owner: OWNER, type: TYPE, id: listing.id, revision, deleted: true };++ const p = listing.passport || {};++ const reads = Array.isArray(p.reads) ? p.reads : [];++ const writes = Array.isArray(p.writes) ? p.writes : [];++ const contacts = Array.isArray(p.contacts) ? p.contacts : [];++ const body = [++ listing.title,++ listing.summary || '',++ listing.description || '',++ listing.wants ? `Wanted in return: ${listing.wants}` : '',++ reads.length ? `Reads: ${reads.join(', ')}` : '',++ writes.length ? `Writes: ${writes.join(', ')}` : '',++ contacts.length ? `Contacts: ${contacts.join(', ')}` : '',++ `Licence: ${listing.license} · version ${listing.version} · ${listing.mode}`,++ ].filter(Boolean).join('\n').slice(0, 48_000);++ return {++ owner: OWNER,++ type: TYPE,++ id: listing.id,++ revision,++ deleted: false,++ visibility: 'public',++ canonical_url: `${String(baseUrl).replace(/\/+$/, '')}${pagePath(listing)}`,++ title: listing.title,++ ...(listing.summary ? { summary: listing.summary } : {}),++ body,++ facets: {++ kind: listing.kind,++ mode: listing.mode,++ license: listing.license,++ creator: listing.creator,++ can_read: reads,++ can_write: writes,++ can_contact: contacts,++ },++ authorship: 'human',++ publication_state: 'published',++ published_at: iso(listing.listed_at || listing.created_at),++ updated_at: iso(listing.updated_at),++ indexability: { decision: 'index' },++ };++}++++function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {++ const nowIso = () => new Date(s.now()).toISOString();++++ async function enqueue(topic, payload, t = null) {++ const db = t || s.db;++ const eventId = ids.newId('event', s.now());++ await db.exec(++ `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,++ [eventId, topic, JSON.stringify(payload), nowIso()],++ );++ return eventId;++ }++++ /** Queue the current document on the caller's transaction (upsert when listed, tombstone otherwise). */++ async function sync(t, listing) {++ const doc = documentOf(listing, { baseUrl });++ return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);++ }++++ async function remove(t, id, revision) {++ return await enqueue(EVENT_DELETED, { owner: OWNER, type: TYPE, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);++ }++++ async function pending() {++ return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE published_at IS NULL'));++ }++++ return { sync, remove, enqueue, documentOf, pending, baseUrl: String(baseUrl).replace(/\/+$/, '') };++}++++module.exports = { createSearchIndex, documentOf, pagePath, OWNER, TYPE, EVENT_UPSERTED, EVENT_DELETED };nnew file mode 100644@@@ -0,0 +1,640 @@++'use strict';++++/**++ * Exchange's data: listings, their permission passports and conformance tests, sandbox trials, installations and++ * free/swap offers.++ *++ * A listing is born `draft` and only its owner can list it, after every conformance test for the current version++ * passed. A write that changes a public listing writes its Search document (or tombstone) in the same transaction++ * (server/search.js). An installation is private: it never becomes a Search document. A sandbox trial is a++ * manifest dry run, not a code execution — Exchange v1 never runs a listing's code (Run has no sandbox today).++ *++ * There is no money in v1: a listing's mode is `free` or `swap`, and its passport's `spends` must be empty.++ * The passport is the listing's public declaration of what it may read, write, spend and contact; installs keep++ * the exact passport they were installed with, so a later edit to the listing cannot widen an old install.++ */++const { cleanText, refusals, SAFETY } = require('./safety');++++const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;++const KINDS = Object.freeze(['agent', 'mcp_tool', 'template', 'theme', 'game_asset', 'service']);++const MODES = Object.freeze(['free', 'swap']);++const STATUSES = Object.freeze(['draft', 'listed', 'hidden', 'closed']);++const CLOSED_REASONS = Object.freeze(['given', 'swapped', 'withdrawn']);++const OFFER_KINDS = Object.freeze(['claim', 'swap']);++const OFFER_STATUSES = Object.freeze(['open', 'accepted', 'declined', 'withdrawn']);++const VERSION_RE = /^\d+\.\d+\.\d+$/;++const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;++const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;++const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;++const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });++++function json(v, fallback) {++ if (v == null) return fallback;++ if (typeof v === 'object') return v;++ try { return JSON.parse(v); } catch { return fallback; }++}++++function stringArray(v) {++ return Array.isArray(v) ? v.map(String) : [];++}++++function wirePassport(v) {++ const p = json(v, {});++ return {++ reads: stringArray(p.reads),++ writes: stringArray(p.writes),++ spends: stringArray(p.spends),++ contacts: stringArray(p.contacts),++ };++}++++function wireManifest(v) {++ const m = json(v, {});++ return { name: m.name || '', entry: m.entry || '', runtime: m.runtime || null };++}++++function wireTest(row) {++ if (!row) return null;++ return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };++}++++function wireListing(row, { tests = null, includeOwner = false, summaryTests = false } = {}) {++ if (!row) return null;++ const out = {++ id: row.id,++ slug: row.slug,++ title: row.title,++ kind: row.kind,++ mode: row.mode,++ summary: row.summary == null ? null : row.summary,++ description: row.description == null ? null : row.description,++ wants: row.wants == null ? null : row.wants,++ license: row.license,++ license_url: row.license_url == null ? null : row.license_url,++ version: row.version,++ creator: row.creator,++ status: row.status,++ revision: Number(row.revision) || 1,++ passport: wirePassport(row.passport),++ manifest: wireManifest(row.manifest),++ listed_at: iso(row.listed_at),++ closed_at: iso(row.closed_at),++ closed_reason: row.closed_reason == null ? null : row.closed_reason,++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ };++ if (includeOwner) out.owner = row.owner == null ? null : row.owner;++ if (summaryTests) {++ const count = Number(row.test_count) || 0;++ const failed = Number(row.test_failed) || 0;++ out.tests = { count, failed, verified: count > 0 && failed === 0 };++ }++ if (tests) out.tests = { count: tests.length, failed: tests.filter((t) => t.status === 'failed').length, verified: tests.length > 0 && tests.every((t) => t.status === 'passed'), rows: tests };++ if (row.install_count != null) out.installs = Number(row.install_count) || 0;++ if (row.offer_count != null) out.open_offers = Number(row.offer_count) || 0;++ return out;++}++++function wireTrial(row, listing = null) {++ if (!row) return null;++ return {++ id: row.id,++ listing_id: row.listing_id,++ version: row.version,++ status: row.status,++ passport: wirePassport(row.passport),++ checks: json(row.checks, []),++ created_at: iso(row.created_at),++ expires_at: iso(row.expires_at),++ consumed_at: iso(row.consumed_at),++ ...(listing ? { listing: { slug: listing.slug, title: listing.title, mode: listing.mode, version: listing.version } } : {}),++ };++}++++function wireInstall(row) {++ if (!row) return null;++ return {++ id: row.id,++ listing_id: row.listing_id,++ version: row.version,++ status: row.status,++ passport: wirePassport(row.passport),++ installed_at: iso(row.installed_at),++ revoked_at: iso(row.revoked_at),++ revision: Number(row.revision) || 1,++ ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license } } : {}),++ };++}++++function wireOffer(row) {++ if (!row) return null;++ return {++ id: row.id,++ listing_id: row.listing_id,++ kind: row.kind,++ body: row.body,++ give: row.give == null ? null : row.give,++ from_name: row.from_name,++ status: row.status,++ created_at: iso(row.created_at),++ decided_at: iso(row.decided_at),++ ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, owner: row.listing_owner || null } } : {}),++ };++}++++function normalizePassport(input) {++ const p = input && typeof input === 'object' ? input : {};++ const allowed = ['reads', 'writes', 'spends', 'contacts'];++ const unknown = Object.keys(p).filter((k) => !allowed.includes(k));++ if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown passport field: ${unknown[0]}`, 'passport');++ const out = {};++ for (const key of ['reads', 'writes']) {++ const list = p[key] == null ? [] : p[key];++ if (!Array.isArray(list)) return refusal(400, 'exchange.invalid', `passport.${key} must be an array of permission names`, 'passport');++ if (list.length > 20) return refusal(400, 'exchange.invalid', `passport.${key} may name at most 20 permissions`, 'passport');++ const seen = new Set();++ const cleaned = [];++ for (const value of list) {++ const s = cleanText(value, 64);++ if (!SCOPE_RE.test(s)) return refusal(400, 'exchange.invalid', `passport.${key} must be lower-case permission names such as search.read`, 'passport');++ if (!seen.has(s)) { seen.add(s); cleaned.push(s); }++ }++ out[key] = cleaned;++ }++ const spends = p.spends == null ? [] : p.spends;++ if (!Array.isArray(spends)) return refusal(400, 'exchange.invalid', 'passport.spends must be an array', 'passport');++ if (spends.length) return refusal(400, 'exchange.no_money', 'v1 is free and swap only: a passport may not declare a spend', 'passport');++ out.spends = [];++ const contacts = p.contacts == null ? [] : p.contacts;++ if (!Array.isArray(contacts)) return refusal(400, 'exchange.invalid', 'passport.contacts must be an array of https origins', 'passport');++ if (contacts.length > 20) return refusal(400, 'exchange.invalid', 'passport.contacts may name at most 20 origins', 'passport');++ out.contacts = [];++ const seenContacts = new Set();++ for (const value of contacts) {++ const s = cleanText(value, 200);++ if (!CONTACT_RE.test(s)) return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport');++ try {++ const u = new URL(s);++ if (u.username || u.password || !u.hostname) throw new Error('credentials');++ const normalized = `${u.origin}${u.pathname === '/' ? '' : u.pathname}`;++ if (!seenContacts.has(normalized)) { seenContacts.add(normalized); out.contacts.push(normalized); }++ } catch { return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport'); }++ }++ return { ok: true, value: out };++}++++function normalizeManifest(input, version) {++ const m = input && typeof input === 'object' ? input : {};++ const allowed = ['name', 'entry', 'runtime'];++ const unknown = Object.keys(m).filter((k) => !allowed.includes(k));++ if (unknown.length) return refusal(400, 'exchange.invalid', `A manifest may name only name, entry and runtime (not ${unknown[0]})`, 'manifest');++ const name = cleanText(m.name, 80);++ const entry = cleanText(m.entry, 200);++ const runtime = cleanText(m.runtime, 80);++ if (name.length < 2 || name.length > 80) return refusal(400, 'exchange.invalid', 'manifest.name must be 2 to 80 characters', 'manifest');++ if (entry.length < 1 || entry.length > 200) return refusal(400, 'exchange.invalid', 'manifest.entry must be 1 to 200 characters', 'manifest');++ if (runtime && (runtime.length < 1 || runtime.length > 80)) return refusal(400, 'exchange.invalid', 'manifest.runtime may be at most 80 characters', 'manifest');++ for (const [key, value] of [['name', name], ['entry', entry], ['runtime', runtime]]) {++ if (SECRET_VALUE_RE.test(value)) return refusal(400, 'exchange.manifest_secret', `manifest.${key} looks like a credential; a manifest is public metadata and must never carry a secret`, 'manifest');++ if (/[\u0000-\u001f\u007f]/.test(value)) return refusal(400, 'exchange.invalid', 'a manifest is plain text: no control characters', 'manifest');++ }++ return { ok: true, value: { name, entry, runtime: runtime || null, version } };++}++++/** Validate a new listing body. Returns { ok, value } or a refusal; the page form and the API share it. */++function normalizeListing(body) {++ const b = body && typeof body === 'object' ? body : {};++ const allowed = ['title', 'kind', 'mode', 'summary', 'description', 'license', 'license_url', 'version', 'wants', 'manifest', 'passport'];++ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));++ if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);++ const title = cleanText(b.title, 120);++ if (title.length < 3 || title.length > 120) return refusal(400, 'exchange.invalid', 'title must be 3 to 120 characters', 'title');++ if (!KINDS.includes(b.kind)) return refusal(400, 'exchange.invalid', `kind must be one of ${KINDS.join(', ')}`, 'kind');++ if (!MODES.includes(b.mode)) return refusal(400, 'exchange.invalid', `mode must be one of ${MODES.join(', ')}`, 'mode');++ const summary = cleanText(b.summary, 600);++ if (summary.length < 3 || summary.length > 600) return refusal(400, 'exchange.invalid', 'summary must be 3 to 600 characters', 'summary');++ const description = cleanText(b.description, 4000);++ const license = cleanText(b.license, 64);++ if (!LICENSE_RE.test(license)) return refusal(400, 'exchange.invalid', 'license must be an SPDX identifier such as CC0-1.0, MIT or AGPL-3.0-only', 'license');++ const licenseUrl = cleanText(b.license_url, 300);++ if (licenseUrl && !/^https:\/\/[^\s"'<>]{1,300}$/.test(licenseUrl)) return refusal(400, 'exchange.invalid', 'license_url must be an https URL', 'license_url');++ const version = cleanText(b.version, 32);++ if (!VERSION_RE.test(version)) return refusal(400, 'exchange.invalid', 'version must be a semantic version such as 1.0.0', 'version');++ const wants = cleanText(b.wants, 300);++ if (b.mode === 'swap' && (wants.length < 3 || wants.length > 300)) return refusal(400, 'exchange.invalid', 'a swap listing must say what it would like in return (3 to 300 characters)', 'wants');++ if (b.mode === 'free' && wants) return refusal(400, 'exchange.invalid', 'a free listing does not name what it wants in return', 'wants');++ const manifest = normalizeManifest(b.manifest, version);++ if (!manifest.ok) return manifest;++ const passport = normalizePassport(b.passport);++ if (!passport.ok) return passport;++ const hit = refusals(title, summary, description, wants);++ if (hit) return hit;++ return {++ ok: true,++ value: {++ title, kind: b.kind, mode: b.mode, summary, description: description || null, license,++ license_url: licenseUrl || null, version, wants: wants || null, manifest: manifest.value, passport: passport.value,++ },++ };++}++++/** The four conformance tests run for every version. A listing is not public until every one passed. */++function checksFor(v) {++ return [++ { name: 'manifest', status: 'passed', detail: 'The manifest names the listing and its entry point; it carries no credential.' },++ { name: 'passport', status: 'passed', detail: 'The permission passport is present and names only read, write and contact grants.' },++ { name: 'licence', status: 'passed', detail: `The licence is ${v.license}.` },++ { name: 'no_money', status: 'passed', detail: `Mode ${v.mode}; the passport declares no spend. ${SAFETY}` },++ ];++}++++function slugify(title) {++ const s = String(title || '')++ .toLowerCase()++ .normalize('NFKD')++ .replace(/[\u0300-\u036f]/g, '')++ .replace(/[^a-z0-9]+/g, '-')++ .replace(/^-+|-+$/g, '')++ .slice(0, 70);++ return s || 'listing';++}++++function createStore(s, { search, config }) {++ const db = s.db;++ const q = (t) => t || db;++ const sandboxTtlMs = Math.max(5, Number(config && config.sandbox && config.sandbox.ttlMinutes) || 60) * 60_000;++ const sandboxPerDay = Math.max(1, Number(config && config.sandbox && config.sandbox.perDay) || 20);++++ async function bySlug(slug, t) {++ return await q(t).maybe('SELECT * FROM exchange_listings WHERE slug = $1', [String(slug || '')]);++ }++ async function byId(id, t) {++ return await q(t).maybe('SELECT * FROM exchange_listings WHERE id = $1', [String(id || '')]);++ }++ async function testsFor(listingId, version = null, t) {++ const rows = version++ ? await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 AND version = $2 ORDER BY id ASC', [listingId, version])++ : await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 ORDER BY id ASC', [listingId]);++ return rows.map(wireTest);++ }++ async function verified(listingId, version, t) {++ const rows = await testsFor(listingId, version, t);++ return rows.length > 0 && rows.every((r) => r.status === 'passed');++ }++++ async function uniqueSlug(base, t) {++ const root = String(base || 'listing').slice(0, 70).replace(/^-+|-+$/g, '') || 'listing';++ let slug = root;++ for (let i = 2; i < 200; i++) {++ const found = await q(t).maybe('SELECT id FROM exchange_listings WHERE slug = $1', [slug]);++ if (!found) return slug;++ slug = `${root}-${i}`;++ }++ return `${root}-${Date.now().toString(36)}`;++ }++++ async function event(t, listingId, from, to, reason, changedBy) {++ await t.exec('INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, $3, $4, $5)',++ [listingId, from, to, reason || null, changedBy || null]);++ }++++ /** Insert one draft and its conformance tests in the caller's transaction. Never publishes. */++ async function createListing({ owner, creator, body }, t = null) {++ const checked = normalizeListing(body);++ if (!checked.ok) return checked;++ const v = checked.value;++ const run = async (tx) => {++ const id = s.newId('exl');++ const slug = await uniqueSlug(slugify(v.title), tx);++ const creatorName = cleanText(creator, 80) || 'a maker';++ const row = await tx.maybe(++ `INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, version,++ creator, owner, status, manifest, passport, revision, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'draft', $14::jsonb, $15::jsonb, 1, now(), now())++ RETURNING *`,++ [id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.version,++ creatorName, owner, JSON.stringify(v.manifest), JSON.stringify(v.passport)],++ );++ const checks = checksFor(v);++ for (const c of checks) {++ await tx.exec('INSERT INTO exchange_listing_tests (listing_id, version, name, status, detail) VALUES ($1, $2, $3, $4, $5)',++ [id, v.version, c.name, c.status, c.detail]);++ }++ await event(tx, id, null, 'draft', 'created', owner);++ const tests = await testsFor(id, v.version, tx);++ return { ok: true, listing: wireListing(row, { tests, includeOwner: true }) };++ };++ return t ? await run(t) : await s.tx(run);++ }++++ /** Public listings, newest first. cursor is an opaque (listed_at, id) keyset position. */++ async function list({ limit = 20, cursor = null, mode = null, kind = null, q: text = null } = {}) {++ const n = Math.min(50, Math.max(1, Number(limit) || 20));++ const args = [];++ let where = "status = 'listed'";++ if (mode) { if (!MODES.includes(mode)) return { error: 'mode' }; args.push(mode); where += ` AND mode = $${args.length}`; }++ if (kind) { if (!KINDS.includes(kind)) return { error: 'kind' }; args.push(kind); where += ` AND kind = $${args.length}`; }++ const term = cleanText(text, 120);++ if (term) {++ args.push(`%${term.replace(/[%_\\]/g, (c) => `\\${c}`)}%`);++ where += ` AND (title ILIKE $${args.length} OR summary ILIKE $${args.length} OR creator ILIKE $${args.length})`;++ }++ let pos = null;++ if (cursor) {++ try {++ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));++ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;++ } catch { /* refused below */ }++ if (!pos) return { error: 'cursor' };++ args.push(pos.t, pos.i);++ where += ` AND (listed_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;++ }++ args.push(n + 1);++ const rows = await db.many(++ `SELECT l.*,++ (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'passed') AS test_count,++ (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'failed') AS test_failed++ FROM exchange_listings l WHERE ${where} ORDER BY listed_at DESC, id DESC LIMIT $${args.length}`,++ args,++ );++ const hasMore = rows.length > n;++ const page = rows.slice(0, n);++ const last = page[page.length - 1];++ return {++ listings: page.map((r) => wireListing(r, { summaryTests: true })),++ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.listed_at), i: last.id })).toString('base64url') : null,++ };++ }++++ /** One listing with its conformance tests and public counters. */++ async function detail(slug) {++ const row = await bySlug(slug);++ if (!row) return null;++ const enriched = await db.maybe(++ `SELECT l.*,++ (SELECT COUNT(*)::int FROM exchange_installs i WHERE i.listing_id = l.id AND i.status = 'active') AS install_count,++ (SELECT COUNT(*)::int FROM exchange_offers o WHERE o.listing_id = l.id AND o.status = 'open') AS offer_count++ FROM exchange_listings l WHERE l.id = $1`,++ [row.id],++ );++ return { row: enriched, listing: wireListing(enriched, { tests: await testsFor(row.id), includeOwner: true }) };++ }++++ async function listMine(owner, { limit = 100 } = {}) {++ const rows = await db.many('SELECT * FROM exchange_listings WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [owner, Math.min(200, Math.max(1, Number(limit) || 100))]);++ const out = [];++ for (const row of rows) out.push(wireListing(row, { tests: await testsFor(row.id) }));++ return out;++ }++++ async function counts() {++ const row = await db.maybe(++ `SELECT COUNT(*)::int AS listings,++ COUNT(*) FILTER (WHERE mode = 'free')::int AS free,++ COUNT(*) FILTER (WHERE mode = 'swap')::int AS swap,++ (SELECT COUNT(DISTINCT owner)::int FROM exchange_listings WHERE status = 'listed' AND owner IS NOT NULL) AS makers,++ (SELECT COUNT(*)::int FROM exchange_installs WHERE status = 'active') AS installs++ FROM exchange_listings WHERE status = 'listed'`,++ );++ return row || { listings: 0, free: 0, swap: 0, makers: 0, installs: 0 };++ }++++ /** Owner publishes a draft (or a closed one cannot be relisted). Every conformance test must have passed. */++ async function publish(slug, { owner } = {}) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');++ if (row.status === 'listed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };++ if (row.status === 'hidden') return refusal(409, 'exchange.hidden', 'Staff hid this listing; it cannot be relisted here.');++ if (row.status === 'closed') return refusal(409, 'exchange.closed', 'A closed listing cannot be relisted; submit a new one.');++ if (!(await verified(row.id, row.version, t))) return refusal(409, 'exchange.tests_not_passed', 'Every conformance test for this version must pass before the listing can be public.');++ const updated = await t.maybe("UPDATE exchange_listings SET status = 'listed', listed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);++ await event(t, row.id, row.status, 'listed', 'listed', owner);++ await search.sync(t, wireListing(updated), []);++ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };++ });++ }++++ async function close(slug, { owner, reason = 'withdrawn' } = {}) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');++ if (row.status === 'closed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };++ const why = CLOSED_REASONS.includes(reason) ? reason : 'withdrawn';++ const updated = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id, why]);++ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);++ await event(t, row.id, row.status, 'closed', why, owner);++ await search.sync(t, wireListing(updated), []);++ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };++ });++ }++++ async function hide(slug, { changedBy } = {}) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row) return refusal(404, 'exchange.not_found', 'No such listing.');++ if (row.status === 'hidden') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };++ const updated = await t.maybe("UPDATE exchange_listings SET status = 'hidden', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);++ await event(t, row.id, row.status, 'hidden', 'staff hide', changedBy);++ await search.sync(t, wireListing(updated), []);++ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };++ });++ }++++ /** A sandbox trial: a manifest dry run against the passport; no listing code is executed. */++ async function sandbox(slug, { subject }) {++ const row = await bySlug(slug);++ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');++ const tests = await testsFor(row.id, row.version);++ const dayStart = new Date(s.now());++ dayStart.setUTCHours(0, 0, 0, 0);++ const used = Number(await db.value('SELECT COUNT(*)::int FROM exchange_sandbox_trials WHERE subject = $1 AND created_at >= $2::timestamptz', [subject, dayStart.toISOString()])) || 0;++ if (used >= sandboxPerDay) return refusal(429, 'exchange.sandbox_limit', 'You have tried as many listings in the sandbox today as v1 allows; try again tomorrow.');++ const passport = wirePassport(row.passport);++ const verifiedTests = tests.length > 0 && tests.every((x) => x.status === 'passed');++ const checks = [++ { name: 'listed', status: 'passed', detail: 'The listing is public.' },++ { name: 'tests', status: verifiedTests ? 'passed' : 'failed', detail: verifiedTests ? `${tests.length} conformance tests passed for version ${row.version}.` : 'The conformance tests for this version are not all passing.' },++ { name: 'passport', status: passport.spends.length === 0 ? 'passed' : 'failed', detail: passport.spends.length === 0 ? 'The passport declares read, write and contact grants only; no spend.' : 'The passport declares a spend, which v1 does not allow.' },++ { name: 'execution', status: 'passed', detail: 'Dry run only: Exchange v1 does not execute the listing. A real Run sandbox needs the run.sandbox.execute grant (README).' },++ ];++ const status = checks.every((c) => c.status === 'passed') ? 'passed' : 'failed';++ const id = s.newId('exs');++ const expiresAt = new Date(s.now() + sandboxTtlMs).toISOString();++ const saved = await db.maybe(++ `INSERT INTO exchange_sandbox_trials (id, listing_id, subject, version, status, passport, checks, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, now(), $8::timestamptz) RETURNING *`,++ [id, row.id, subject, row.version, status, JSON.stringify(passport), JSON.stringify(checks), expiresAt],++ );++ return { ok: true, trial: wireTrial(saved, row), checks, listing: wireListing(row) };++ }++++ async function trialById(id, { subject }) {++ const row = await db.maybe(++ `SELECT t.*, l.slug, l.title, l.mode, l.version AS listing_version FROM exchange_sandbox_trials t++ JOIN exchange_listings l ON l.id = t.listing_id WHERE t.id = $1 AND t.subject = $2`,++ [String(id || ''), subject],++ );++ if (!row) return null;++ const listing = { slug: row.slug, title: row.title, mode: row.mode, version: row.listing_version };++ return { row, trial: wireTrial(row, listing), listing };++ }++++ async function hasPassedTrial(listingId, subject, version) {++ const row = await db.maybe(++ `SELECT id FROM exchange_sandbox_trials++ WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz++ ORDER BY created_at DESC LIMIT 1`,++ [listingId, subject, version, s.iso()],++ );++ return Boolean(row);++ }++++ async function activeInstall(listingId, subject) {++ const row = await db.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [listingId, subject]);++ return wireInstall(row);++ }++++ async function install(slug, { subject }) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');++ const existing = await t.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [row.id, subject]);++ if (existing) return { ok: true, install: wireInstall({ ...existing, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: true };++ const trial = await t.maybe(++ `SELECT * FROM exchange_sandbox_trials++ WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz++ ORDER BY created_at DESC LIMIT 1`,++ [row.id, subject, row.version, s.iso()],++ );++ if (!trial) return refusal(409, 'exchange.sandbox_required', 'Try this version in the sandbox first; a passed trial is required before an install.');++ const id = s.newId('exi');++ const saved = await t.maybe(++ `INSERT INTO exchange_installs (id, listing_id, subject, version, passport, status, installed_at, revision)++ VALUES ($1, $2, $3, $4, $5::jsonb, 'active', now(), 1) RETURNING *`,++ [id, row.id, subject, row.version, JSON.stringify(wirePassport(row.passport))],++ );++ await t.exec('UPDATE exchange_sandbox_trials SET consumed_at = now() WHERE id = $1', [trial.id]);++ return { ok: true, install: wireInstall({ ...saved, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: false };++ });++ }++++ async function installsFor(subject) {++ const rows = await db.many(++ `SELECT i.*, l.slug, l.title, l.mode, l.kind, l.license FROM exchange_installs i++ JOIN exchange_listings l ON l.id = i.listing_id WHERE i.subject = $1 ORDER BY i.installed_at DESC`,++ [subject],++ );++ return rows.map(wireInstall);++ }++++ async function revoke(installId, { subject }) {++ return await s.tx(async (t) => {++ const row = await t.maybe('SELECT * FROM exchange_installs WHERE id = $1 AND subject = $2', [String(installId || ''), subject]);++ if (!row) return refusal(404, 'exchange.install_not_found', 'No such installation of yours.');++ if (row.status === 'revoked') return { ok: true, install: wireInstall(row), unchanged: true };++ const updated = await t.maybe("UPDATE exchange_installs SET status = 'revoked', revoked_at = now(), revision = revision + 1 WHERE id = $1 RETURNING *", [row.id]);++ // v1 has no Network grant to revoke: this record is the entitlement. v2 calls Network's grant revoke++ // with the exchange service token (README, "Grants it will need").++ return { ok: true, install: wireInstall(updated), unchanged: false };++ });++ }++++ /** A free claim or a swap offer. The owner cannot offer on their own listing. */++ async function offer(slug, { subject, fromName, body, give }) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');++ if (row.owner === subject) return refusal(400, 'exchange.own_listing', 'This is your listing; you cannot claim or swap with yourself.');++ const existing = await t.maybe("SELECT id FROM exchange_offers WHERE listing_id = $1 AND from_subject = $2 AND status = 'open'", [row.id, subject]);++ if (existing) return refusal(409, 'exchange.offer_exists', 'You already have an open offer on this listing.');++ const kind = row.mode === 'free' ? 'claim' : 'swap';++ const message = cleanText(body, 1000);++ if (message.length < 3 || message.length > 1000) return refusal(400, 'exchange.invalid', 'Say something to the maker (3 to 1000 characters).', 'body');++ const giving = cleanText(give, 1000);++ if (kind === 'swap' && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A swap offer must say what you offer in return (3 to 1000 characters).', 'give');++ if (kind === 'claim' && giving && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A claim may include a note of 3 to 1000 characters, or nothing at all.', 'give');++ const hit = refusals(message, giving);++ if (hit) return hit;++ const id = s.newId('exo');++ const saved = await t.maybe(++ `INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body, give, status, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, 'open', now()) RETURNING *`,++ [id, row.id, subject, cleanText(fromName, 80) || 'a maker', kind, message, giving || null],++ );++ return { ok: true, offer: wireOffer({ ...saved, slug: row.slug, title: row.title, mode: row.mode, listing_owner: row.owner }) };++ });++ }++++ async function offersFor(subject) {++ const sent = await db.many(++ `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o++ JOIN exchange_listings l ON l.id = o.listing_id WHERE o.from_subject = $1 ORDER BY o.created_at DESC`,++ [subject],++ );++ const received = await db.many(++ `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o++ JOIN exchange_listings l ON l.id = o.listing_id WHERE l.owner = $1 ORDER BY o.created_at DESC`,++ [subject],++ );++ return { sent: sent.map(wireOffer), received: received.map(wireOffer) };++ }++++ async function offersForListing(listingId) {++ const rows = await db.many("SELECT * FROM exchange_offers WHERE listing_id = $1 AND status = 'open' ORDER BY created_at ASC", [listingId]);++ return rows.map(wireOffer);++ }++++ /** accept/decline are the owner's; withdraw is the offerer's. Accept closes the listing (given or swapped). */++ async function decideOffer(offerId, { subject, action }) {++ if (!['accept', 'decline', 'withdraw'].includes(action)) return refusal(400, 'exchange.invalid', 'action must be accept, decline or withdraw');++ return await s.tx(async (t) => {++ const o = await t.maybe('SELECT * FROM exchange_offers WHERE id = $1', [String(offerId || '')]);++ if (!o) return refusal(404, 'exchange.offer_not_found', 'No such offer.');++ const listing = await byId(o.listing_id, t);++ if (!listing) return refusal(404, 'exchange.offer_not_found', 'No such offer.');++ if (action === 'withdraw' && o.from_subject !== subject) return refusal(403, 'exchange.offer_not_yours', 'Only the person who made an offer can withdraw it.');++ if (action !== 'withdraw' && listing.owner !== subject) return refusal(403, 'exchange.owner_only', 'Only the listing owner can accept or decline an offer.');++ if (o.status !== 'open') return { ok: true, offer: wireOffer(o), unchanged: true };++ const status = action === 'accept' ? 'accepted' : action === 'decline' ? 'declined' : 'withdrawn';++ const updated = await t.maybe("UPDATE exchange_offers SET status = $2, decided_at = now() WHERE id = $1 RETURNING *", [o.id, status]);++ let closed = null;++ if (status === 'accepted') {++ const why = listing.mode === 'free' ? 'given' : 'swapped';++ const updatedListing = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [listing.id, why]);++ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND id <> $2 AND status = 'open'", [listing.id, o.id]);++ await event(t, listing.id, listing.status, 'closed', why, subject);++ await search.sync(t, wireListing(updatedListing), []);++ closed = wireListing(updatedListing, { tests: await testsFor(listing.id), includeOwner: true });++ }++ return { ok: true, offer: wireOffer(updated), closed, unchanged: false };++ });++ }++++ /** Imported/seedable listing used by scripts/seed-proof.js: goes through the same validation and tests. */++ async function seedListing({ owner, creator, body }) {++ return await createListing({ owner, creator, body });++ }++++ return {++ wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,++ normalizeListing, normalizePassport, normalizeManifest, checksFor,++ bySlug, byId, testsFor, verified, uniqueSlug,++ createListing, list, detail, listMine, counts,++ publish, close, hide,++ sandbox, trialById,++ hasPassedTrial, activeInstall, install, installsFor, revoke,++ offer, offersFor, offersForListing, decideOffer,++ seedListing,++ KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, SAFETY,++ };++}++++module.exports = {++ createStore, slugify, normalizeListing, normalizePassport, normalizeManifest, checksFor,++ wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,++ SLUG_RE, KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, VERSION_RE, LICENSE_RE,++};nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community, MediaHub and++ * Meme): an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. Exchange++ * v1 has no AI-written copy — a listing's words are the maker's own — but noLooks is here for the day an AI++ * summary, sandbox description or catalogue line is generated. A summary that names looks is replaced by the++ * listing's title, exactly as Live and Meme do; a maker's own verbatim text is never rewritten by this module.++ */++const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');++++/** The summary to store for an AI-written listing summary. */++function cleanSummary(title, summary) {++ const t = String(title || '').trim() || 'Untitled listing';++ const s = String(summary == null ? '' : summary).trim();++ if (!s) return t;++ if (hasLooks(s)) return t;++ const cleaned = String(noLooks(s) || '').trim();++ return cleaned || t;++}++++module.exports = { cleanSummary, noLooks, hasLooks };nnew file mode 100644@@@ -0,0 +1,432 @@++'use strict';++++/**++ * The Exchange room's pages (plan 2026-10-28 §5.11). Server-rendered and whole without JavaScript: every action is a++ * plain form or link. public/js/exchange.js and public/css/exchange.css are one-comment placeholders the main++ * session replaces; the sandbox, install, revoke and offers work without them.++ *++ * home({ listings, counts }) the hero, the three scenes, public listings++ * listingsPage({ listings, filters }) browse and filter++ * listingPage({ listing, tests, ... }) passport, tests, sandbox, install/revoke and offers++ * submitForm({ values, mine, problem }) create a listing (draft)++ * sandboxPage({ trial, listing }) one sandbox trial and its checks++ * installsPage({ installs }) the caller's installations++ * offersPage({ sent, received }) free claims and swap offers++ * safetyPage() free and swap safety rules++ * developersPage() the API++ * signIn({ title, text, next }) a page that needs an account++ * notFound()++ * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are++ * stable and start with `ex-`.++ */++const { esc, time } = require('../render/html');++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++++const KIND_LABEL = { agent: 'Agent', mcp_tool: 'MCP tool', template: 'Template', theme: 'Theme or kit', game_asset: 'Game asset', service: 'Creator service' };++const MODE_LABEL = { free: 'Free', swap: 'Swap' };++const STATUS_LABEL = { draft: 'Draft', listed: 'Listed', hidden: 'Hidden', closed: 'Closed' };++const CLOSED_LABEL = { given: 'Given away', swapped: 'Swapped', withdrawn: 'Withdrawn' };++const PASSPORT_LABEL = { reads: 'Can read', writes: 'Can write', spends: 'Can spend', contacts: 'Can contact' };++++const kindLabel = (k) => esc(KIND_LABEL[k] || k);++const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;++const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;++++function verifiedBadge(listing) {++ if (!listing || !listing.tests) return '';++ if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;++ return `<span class="ex-unverified">${Number(listing.tests.failed) ? 'tests failed' : 'not tested'}</span>`;++}++++function card(l) {++ return `<li class="ex-listing-card" data-listing="${esc(l.slug)}" data-kind="${esc(l.kind)}" data-mode="${esc(l.mode)}">++<a class="ex-listing-link" href="/listings/${esc(l.slug)}">++<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span>++<span class="ex-listing-title">${esc(l.title)}</span>++<span class="ex-listing-summary">${esc(l.summary)}</span>++<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span>++</a></li>`;++}++++function empty(text, href = '/submit', label = 'List something') {++ return `<div class="ex-empty"><p>${esc(text)}</p><a class="ex-btn ex-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;++}++++function passport(p = {}, { id = 'ex-passport' } = {}) {++ const rows = ['reads', 'writes', 'spends', 'contacts'];++ const value = (key) => {++ const list = Array.isArray(p[key]) ? p[key] : [];++ if (!list.length) return key === 'spends' ? '<span class="ex-none">Nothing — v1 has no money</span>' : '<span class="ex-none">Nothing</span>';++ return `<ul class="ex-passport-list">${list.map((x) => `<li>${key === 'contacts' ? `<code>${esc(x)}</code>` : `<code>${esc(x)}</code>`}</li>`).join('')}</ul>`;++ };++ return `<section class="ex-passport"${id ? ` id="${id}"` : ''} aria-label="Permission passport">++<div class="ex-section-head"><h2>Permission passport</h2><span class="ex-passport-stamp" aria-hidden="true">passport</span></div>++<p class="ex-fine">Exactly what this listing declares it can read, write, spend and contact. The passport is checked in the sandbox and snapshotted at install; a later edit to the listing cannot widen an old install.</p>++<dl class="ex-passport-grid">++${rows.map((key) => `<div class="ex-passport-row ex-passport-${key}"><dt>${esc(PASSPORT_LABEL[key])}</dt><dd>${value(key)}</dd></div>`).join('')}++</dl>++</section>`;++}++++function testsBlock(tests = []) {++ return `<section class="ex-tests-sec" id="ex-tests" aria-labelledby="ex-tests-h">++<div class="ex-section-head"><h2 id="ex-tests-h">Tests for this version</h2>${tests.length && tests.every((t) => t.status === 'passed') ? '<span class="ex-verified">✓ all pass</span>' : '<span class="ex-unverified">not passing</span>'}</div>++${tests.length ? `<ul class="ex-tests">${tests.map((t) => `<li class="ex-test ex-test-${esc(t.status)}"><span class="ex-test-name"><b>${esc(t.name)}</b> <span class="ex-test-status">${esc(t.status)}</span></span><span class="ex-test-detail">${esc(t.detail || '')}</span></li>`).join('')}</ul>` : '<p class="ex-muted">No conformance test has been recorded for this version.</p>'}++</section>`;++}++++/** The room's picture: two arrows looping an item between makers, and a passport stamping. */++function motif() {++ return `<div class="ex-motif" aria-hidden="true">++<svg class="ex-motif-arrows" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">++<path d="M84 92c32-46 92-62 148-42 30 11 54 34 68 62" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>++<path d="m286 96 16 18-24 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>++<path d="M336 168c-32 46-92 62-148 42-30-11-54-34-68-62" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"/>++<path d="m134 164-16-18 24-6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity=".55"/>++</svg>++<span class="ex-motif-item ex-motif-item-a">a template</span>++<span class="ex-motif-item ex-motif-item-b">a theme</span>++<span class="ex-motif-passport"><small>passport</small><b>safe</b></span>++</div>`;++}++++function home({ listings = [], counts = {} } = {}) {++ const live = `<section class="ex-section" id="ex-listings" aria-labelledby="ex-listings-h">++<div class="ex-section-head"><h2 id="ex-listings-h">Fresh listings</h2><a class="ex-more" href="/listings">All listings${kit.icon('chevron')}</a></div>++${listings.length ? `<ol class="ex-grid">${listings.slice(0, 8).map(card).join('')}</ol>` : empty('Nothing is listed yet. The first proof is a licensed template, tried in the sandbox, installed and revoked.')}++</section>`;++ const room = sc.room({++ room: 'exchange',++ hero: {++ title: 'Trade what people make',++ lede: 'Agents, MCP tools, templates, themes, game assets and creator services — each with a permission passport that says exactly what it can read, write, spend and contact. Try it in the sandbox, install it with those grants, revoke it in one tap. Free and swap only in v1: there is no money.',++ start: { href: '/listings', label: 'Browse the listings' },++ chips: [{ label: 'List something', href: '/submit' }, { label: 'Safety rules', href: '/safety' }, { label: 'The API', href: '/developers' }],++ proof: Number(counts.listings) ? [++ { value: Number(counts.listings), label: Number(counts.listings) === 1 ? 'listing' : 'listings' },++ { value: Number(counts.free) || 0, label: 'free' },++ { value: Number(counts.swap) || 0, label: 'swap' },++ { value: Number(counts.makers) || 0, label: Number(counts.makers) === 1 ? 'maker' : 'makers' },++ ] : [],++ motif: motif(),++ },++ scenes: [++ {++ title: 'Try it in the sandbox',++ text: 'A sandbox trial reads the listing\'s manifest and checks its passport before anything is installed. v1 is an honest dry run: Exchange does not execute a listing\'s code, and says so. A passed trial is good for one hour and is required before an install.',++ html: `<div class="ex-demo ex-demo-sandbox"><span class="ex-demo-head">sandbox trial</span><ul class="ex-demo-checks"><li>✓ manifest names the entry point</li><li>✓ passport declares no spend</li><li>✓ 4 conformance tests passed</li><li class="ex-muted">dry run: no listing code runs</li></ul></div>`,++ action: { href: '/listings', label: 'Find something to try' },++ },++ {++ title: 'Install it and see its passport grants',++ text: 'The install keeps the exact passport it was made with: read, write and contact grants, and no spend in v1. The listing page shows the same passport before and after, so nothing arrives hidden.',++ html: `<div class="ex-demo ex-demo-passport"><span class="ex-demo-head">permission passport</span><ul class="ex-demo-grants"><li><b>reads</b> search.read · space.files.read</li><li><b>writes</b> space.files.write</li><li><b>spends</b> nothing</li><li><b>contacts</b> api.example.com</li></ul><span class="ex-demo-stamp">installed</span></div>`,++ action: { href: '/installs', label: 'See your installations' },++ },++ {++ title: 'Revoke it in one tap',++ text: 'A revoke marks the installation revoked on the spot and removes the passport with it. In v1 the Exchange install record is the entitlement; v2 also calls Network\'s grant revoke, so the access disappears everywhere at once.',++ html: `<div class="ex-demo ex-demo-revoke"><span class="ex-demo-head">your installation</span><span class="ex-demo-row"><b>A starter site</b><span class="ex-ok">active</span></span><span class="ex-demo-row ex-muted">passport snapshot · 2026-10-28</span><span class="ex-demo-button">Revoke</span></div>`,++ action: { href: '/installs', label: 'Manage installations' },++ },++ ],++ faq: {++ items: [++ ['What is a permission passport?', 'A listing\'s public declaration of what it can read, write, spend and contact, taken from its manifest. It is shown on every listing, checked in the sandbox and snapshotted at install. In v1 a passport may not declare a spend at all.'],++ ['Is anything paid?', 'No. v1 is free and swap only: no prices, no Billing, no cash, no Vibes, and no way to pay through Exchange. A listing or offer that asks for money is refused.'],++ ['What does the sandbox actually run?', 'In v1, nothing of the listing\'s. It is a manifest and passport dry run that records its checks. A real Run sandbox is the next version\'s job; the capability it will need is named in the README.'],++ ['How do I give something away or swap it?', 'List it as free or swap. A free listing takes claims; a swap listing asks what you would like in return and takes offers. Accepting one closes the listing as given or swapped.'],++ ['Can I take something back?', 'Yes. Revoke an installation in one tap from Installations. Closing a listing stops new offers and removes it from search and the public list; an install you already made stays until you revoke it.'],++ ],++ },++ });++ const at = room.indexOf('<section class="sc-room-scenes"');++ return `<div class="ex-wrap ex-home" id="ex-home">${at < 0 ? room : room.slice(0, at) + live + room.slice(at)}</div>`;++}++++function filtersBar(filters = {}) {++ const f = { q: '', mode: '', kind: '', ...filters };++ return `<form class="ex-filters" id="ex-filters" method="get" action="/listings" role="search">++<label class="ex-field"><span>Search</span><input type="search" name="q" value="${esc(f.q)}" maxlength="120" placeholder="a template, a theme, a maker…"></label>++<label class="ex-field"><span>Mode</span><select name="mode"><option value="">Free and swap</option><option value="free"${f.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${f.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>++<label class="ex-field"><span>Kind</span><select name="kind"><option value="">Every kind</option>${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${f.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>++<button class="ex-btn" type="submit">Filter</button>++</form>`;++}++++function listingsPage({ listings = [], nextCursor = null, filters = {} } = {}) {++ const q = filters.q ? `&q=${encodeURIComponent(filters.q)}` : '';++ const mode = filters.mode ? `&mode=${encodeURIComponent(filters.mode)}` : '';++ const kind = filters.kind ? `&kind=${encodeURIComponent(filters.kind)}` : '';++ return `<div class="ex-wrap" id="ex-listings-page">++<header class="ex-page-h"><p class="ex-kicker">The marketplace</p><h1>Everything people make</h1>++<p class="ex-lede">Free and swap only in v1. Every listing carries its permission passport, its licence and the conformance tests for its version. Try before you install; revoke any time.</p></header>++${filtersBar(filters)}++${listings.length ? `<ol class="ex-grid">${listings.map(card).join('')}</ol>` : empty('No listing matches this filter yet.', '/listings', 'Clear the filters')}++${nextCursor ? `<p class="ex-more-row"><a class="ex-btn ex-btn-quiet" href="/listings?cursor=${encodeURIComponent(nextCursor)}${q}${mode}${kind}">More listings</a></p>` : ''}++</div>`;++}++++function offerList(offers = [], { owner = false } = {}) {++ if (!offers.length) return '<p class="ex-muted">No open claim or offer yet.</p>';++ return `<ul class="ex-offers-list">${offers.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}">++<div class="ex-offer-head"><b class="ex-offer-from">${esc(o.from_name)}</b><span class="ex-offer-kind">${o.kind === 'claim' ? 'claims this' : 'offers a swap'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div>++<p class="ex-offer-body">${esc(o.body)}</p>++${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}++${owner ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept${o.kind === 'claim' ? ' the claim' : ' the swap'}</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>`}++</li>`).join('')}</ul>`;++}++++function listingPage({++ listing: l, tests = [], mine = false, staff = false, signedIn = false, activeInstall = null,++ trialPassed = false, offers = [], received = false, problem = null, notice = null,++} = {}) {++ const offersOpen = l.status === 'listed';++ const ownerPanel = mine ? `<section class="ex-owner" id="ex-owner" aria-labelledby="ex-owner-h">++<div class="ex-section-head"><h2 id="ex-owner-h">Your listing</h2>${statusBadge(l.status)}</div>++<p class="ex-fine">A draft becomes public only when every conformance test for this version has passed. Closing it stops new offers and removes it from the public list; a closed listing cannot be relisted.</p>++${l.status === 'draft' ? `<form method="post" action="/listings/${esc(l.slug)}/list" class="ex-inline"><button class="ex-btn" type="submit"${l.tests && l.tests.verified ? '' : ' disabled'}>List it publicly</button></form>` : ''}++${l.status === 'draft' && !(l.tests && l.tests.verified) ? '<p class="ex-note is-bad">Fix the failed tests before this can be public.</p>' : ''}++${l.status === 'listed' ? `<form method="post" action="/listings/${esc(l.slug)}/close" class="ex-inline"><input type="hidden" name="reason" value="withdrawn"><button class="ex-btn ex-btn-quiet" type="submit">Close the listing</button></form>` : ''}++${l.status === 'closed' ? `<p class="ex-muted">Closed: ${esc(CLOSED_LABEL[l.closed_reason] || l.closed_reason || 'closed')}.</p>` : ''}++</section>` : '';++++ const sandbox = offersOpen ? `<section class="ex-sandbox-sec" id="ex-sandbox" aria-labelledby="ex-sandbox-h">++<div class="ex-section-head"><h2 id="ex-sandbox-h">Try it in the sandbox</h2>${trialPassed ? '<span class="ex-verified">✓ trial passed</span>' : ''}</div>++<p class="ex-fine">A manifest and passport dry run. v1 does not execute the listing\'s code; it records the checks and gives you a one-hour pass to install.</p>++${signedIn ? `<form method="post" action="/listings/${esc(l.slug)}/sandbox" class="ex-inline"><button class="ex-btn" type="submit">Run the sandbox</button></form>` : `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to try this listing in the sandbox.</p>`}++</section>` : '';++++ const install = offersOpen ? `<section class="ex-install-sec" id="ex-install" aria-labelledby="ex-install-h">++<div class="ex-section-head"><h2 id="ex-install-h">Install</h2>${activeInstall ? '<span class="ex-status ex-status-active">installed</span>' : ''}</div>++${activeInstall ? `<p class="ex-fine">Installed version ${esc(activeInstall.version)} with the passport below. Revoking removes it here and, from v2, at Network too.</p><form method="post" action="/installs/${esc(activeInstall.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke in one tap</button></form>` : ''}++${!activeInstall && trialPassed ? `<p class="ex-fine">Your sandbox trial passed for version ${esc(l.version)}. The install keeps this passport; it does not widen it.</p><form method="post" action="/listings/${esc(l.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}++${!activeInstall && !trialPassed && signedIn && !mine ? '<p class="ex-note">Run the sandbox first: a passed trial for this version is required before an install.</p>' : ''}++${!signedIn ? `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to install.</p>` : ''}++</section>` : '';++++ const offerForm = offersOpen && signedIn && !mine ? `<section class="ex-offer-form-sec" id="ex-offer-form" aria-labelledby="ex-offer-form-h">++<div class="ex-section-head"><h2 id="ex-offer-form-h">${l.mode === 'free' ? 'Claim this' : 'Offer a swap'}</h2>${modeBadge(l.mode)}</div>++<form method="post" action="/listings/${esc(l.slug)}/offers" class="ex-form">++<label class="ex-field"><span>${l.mode === 'free' ? 'Say why you would like it' : 'What you offer in return'}</span><textarea name="${l.mode === 'free' ? 'body' : 'body'}" rows="3" maxlength="1000" required></textarea></label>++${l.mode === 'swap' ? `<label class="ex-field"><span>What you are giving</span><textarea name="give" rows="3" maxlength="1000" required></textarea></label>` : ''}++<button class="ex-btn" type="submit">${l.mode === 'free' ? 'Send the claim' : 'Send the offer'}</button>++</form>++<p class="ex-fine">No money changes hands. A swap is between the two people; accepting one closes the listing.</p>++</section>` : '';++++ const offersSection = mine && offers.length ? `<section class="ex-offers-sec" id="ex-offers" aria-labelledby="ex-offers-h">++<div class="ex-section-head"><h2 id="ex-offers-h">Open ${l.mode === 'free' ? 'claims' : 'offers'}</h2><span class="ex-count">${offers.length}</span></div>++${offerList(offers, { owner: true })}++</section>` : '';++++ return `<div class="ex-wrap" id="ex-listing" data-listing="${esc(l.slug)}">++<article class="ex-listing">++<header class="ex-listing-h">++<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p>++<h1>${esc(l.title)}</h1>++<p class="ex-lede">${esc(l.summary)}</p>++<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}</p>++</header>++++${notice ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ''}++${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}++++<section class="ex-about" id="ex-about" aria-labelledby="ex-about-h">++<h2 id="ex-about-h">About this listing</h2>++${l.description ? `<div class="ex-prose">${esc(l.description).replace(/\n/g, '<br>')}</div>` : '<p class="ex-muted">The maker has not added a longer description.</p>'}++${l.mode === 'swap' && l.wants ? `<p class="ex-wants"><b>Wants in return:</b> ${esc(l.wants)}</p>` : ''}++<dl class="ex-facts">++<div><dt>Version</dt><dd>${esc(l.version)}</dd></div>++<div><dt>Licence</dt><dd>${esc(l.license)}</dd></div>++<div><dt>Mode</dt><dd>${esc(MODE_LABEL[l.mode] || l.mode)}</dd></div>++<div><dt>Listed</dt><dd>${l.listed_at ? time(l.listed_at) : 'not yet'}</dd></div>++<div><dt>Revision</dt><dd>${Number(l.revision) || 1}</dd></div>++</dl>++</section>++++${passport(l.passport)}++${testsBlock(tests)}++${sandbox}++${install}++${offersSection}++${offerForm}++${ownerPanel}++++<section class="ex-manifest" id="ex-manifest" aria-labelledby="ex-manifest-h">++<div class="ex-section-head"><h2 id="ex-manifest-h">Manifest</h2></div>++<dl class="ex-facts"><div><dt>Name</dt><dd>${esc(l.manifest ? l.manifest.name : '')}</dd></div><div><dt>Entry</dt><dd><code>${esc(l.manifest ? l.manifest.entry : '')}</code></dd></div><div><dt>Runtime</dt><dd>${esc(l.manifest && l.manifest.runtime ? l.manifest.runtime : 'not stated')}</dd></div></dl>++<p class="ex-fine">Public metadata only. A manifest that looks like a credential is refused when the listing is submitted.</p>++</section>++++<footer class="ex-listing-foot"><p class="ex-fine">${esc(SAFETY_NOTE)}</p></footer>++</article>++</div>`;++}++++const SAFETY_NOTE = 'v1 is free and swap only: nobody should send money. Read the passport before installing, and revoke anything you no longer use.';++++const textarea = (name, value, rows = 4, max = 4000) => `<textarea name="${name}" rows="${rows}" maxlength="${max}">${esc(value || '')}</textarea>`;++++function submitForm({ values = {}, mine = [], problem = null } = {}) {++ const v = {++ title: '', kind: 'template', mode: 'free', summary: '', description: '', wants: '', license: 'CC0-1.0',++ license_url: '', version: '1.0.0', manifest_name: '', manifest_entry: '', manifest_runtime: '',++ passport_reads: '', passport_writes: '', passport_contacts: '', ...values,++ };++ return `<div class="ex-wrap" id="ex-submit">++<header class="ex-page-h"><p class="ex-kicker">List something</p><h1>Trade what you make</h1>++<p class="ex-lede">A listing is a draft first. Exchange runs the conformance tests when you submit it; when they pass you can list it publicly. v1 is free or swap only: there is no price and the passport may not declare a spend.</p></header>++${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}++<form id="ex-listing-form" class="ex-form ex-card" method="post" action="/submit">++<label class="ex-field"><span>Title</span><input name="title" type="text" maxlength="120" required value="${esc(v.title)}" placeholder="A starter website template"></label>++<div class="ex-field-row">++<label class="ex-field"><span>Kind</span><select name="kind">${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${v.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>++<label class="ex-field"><span>Mode</span><select name="mode"><option value="free"${v.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${v.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>++</div>++<label class="ex-field"><span>Summary</span><input name="summary" type="text" maxlength="600" required value="${esc(v.summary)}" placeholder="What it is, in one line."></label>++<label class="ex-field"><span>Description</span>${textarea('description', v.description, 6, 4000)}</label>++<label class="ex-field"><span>What a swap wants in return (swap only)</span><input name="wants" type="text" maxlength="300" value="${esc(v.wants)}" placeholder="A type kit, a game map, a skill swap…"></label>++<div class="ex-field-row">++<label class="ex-field"><span>Licence (SPDX)</span><input name="license" type="text" maxlength="64" required value="${esc(v.license)}" placeholder="CC0-1.0"></label>++<label class="ex-field"><span>Licence URL (optional)</span><input name="license_url" type="url" maxlength="300" value="${esc(v.license_url)}" placeholder="https://…"></label>++<label class="ex-field"><span>Version</span><input name="version" type="text" maxlength="32" required value="${esc(v.version)}" placeholder="1.0.0"></label>++</div>++<fieldset class="ex-fieldset"><legend>Permission passport</legend>++<p class="ex-fine">One permission per line. Reads and writes are lower-case names such as <code>search.read</code> or <code>space.files.write</code>; contacts are https origins. Spending is fixed to nothing in v1.</p>++<label class="ex-field"><span>Can read</span>${textarea('passport_reads', v.passport_reads, 3, 1000)}</label>++<label class="ex-field"><span>Can write</span>${textarea('passport_writes', v.passport_writes, 3, 1000)}</label>++<label class="ex-field"><span>Can contact</span>${textarea('passport_contacts', v.passport_contacts, 3, 1000)}</label>++<label class="ex-field"><span>Can spend</span><input type="text" value="Nothing — v1 has no money" disabled></label>++</fieldset>++<fieldset class="ex-fieldset"><legend>Manifest</legend>++<p class="ex-fine">Public metadata only: the name, the entry point and the runtime. Never put a secret, token or key in a manifest; it is refused.</p>++<label class="ex-field"><span>Manifest name</span><input name="manifest_name" type="text" maxlength="80" required value="${esc(v.manifest_name)}" placeholder="Starter site"></label>++<label class="ex-field"><span>Entry point</span><input name="manifest_entry" type="text" maxlength="200" required value="${esc(v.manifest_entry)}" placeholder="templates/starter/index.html"></label>++<label class="ex-field"><span>Runtime</span><input name="manifest_runtime" type="text" maxlength="80" value="${esc(v.manifest_runtime)}" placeholder="static"></label>++</fieldset>++<div class="ex-actions"><button class="ex-btn" type="submit">Save the draft</button><a class="ex-btn ex-btn-quiet" href="/listings">Cancel</a></div>++<p class="ex-fine">By listing you confirm you have the rights to what you share and that it follows the <a href="/safety">safety rules</a>.</p>++</form>++${mine.length ? `<section class="ex-mine" id="ex-mine" aria-labelledby="ex-mine-h"><div class="ex-section-head"><h2 id="ex-mine-h">Your listings</h2><span class="ex-count">${mine.length}</span></div><ul class="ex-mine-list">${mine.map((l) => `<li class="ex-mine-item"><a href="/listings/${esc(l.slug)}">${esc(l.title)}</a> ${statusBadge(l.status)} ${modeBadge(l.mode)} <span class="ex-muted">v${esc(l.version)}</span></li>`).join('')}</ul></section>` : ''}++</div>`;++}++++function sandboxPage({ trial, listing } = {}) {++ const t = trial;++ return `<div class="ex-wrap" id="ex-sandbox-trial" data-trial="${esc(t.id)}">++<header class="ex-page-h"><p class="ex-kicker">Sandbox trial</p><h1>${t.status === 'passed' ? 'The dry run passed' : 'The dry run did not pass'}</h1>++<p class="ex-lede">${esc(listing ? listing.title : '')} · version ${esc(t.version)}. ${t.status === 'passed' ? 'You can install this version with its passport until the trial expires.' : 'Nothing was installed. Fix the failed checks and try again.'}</p></header>++<p class="ex-note ${t.status === 'passed' ? '' : 'is-bad'}" role="status">Status: <b>${esc(t.status)}</b> · expires ${time(t.expires_at)}</p>++<ul class="ex-checks">${(t.checks || []).map((c) => `<li class="ex-check ex-check-${esc(c.status)}"><b>${esc(c.name)}</b> <span>${esc(c.status)}</span><span class="ex-check-detail">${esc(c.detail || '')}</span></li>`).join('')}</ul>++${passport(t.passport)}++<div class="ex-actions">++${t.status === 'passed' && !t.consumed_at && listing ? `<form method="post" action="/listings/${esc(listing.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}++${t.consumed_at ? '<p class="ex-fine">This trial was used by an install. Run a fresh one to install again.</p>' : ''}++<a class="ex-btn ex-btn-quiet" href="/listings/${esc(listing ? listing.slug : '')}">Back to the listing</a>++</div>++</div>`;++}++++function installsPage({ installs = [] } = {}) {++ const active = installs.filter((i) => i.status === 'active');++ const revoked = installs.filter((i) => i.status !== 'active');++ return `<div class="ex-wrap" id="ex-installs">++<header class="ex-page-h"><p class="ex-kicker">Your installations</p><h1>What you have installed</h1>++<p class="ex-lede">Each install keeps the exact passport it was made with. Revoke in one tap; installing again needs a fresh sandbox trial, so the passport is re-checked.</p></header>++<section class="ex-install-active" id="ex-installs-active" aria-labelledby="ex-installs-active-h">++<div class="ex-section-head"><h2 id="ex-installs-active-h">Active</h2><span class="ex-count">${active.length}</span></div>++${active.length ? `<ul class="ex-install-list">${active.map((i) => `<li class="ex-install" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">${esc(KIND_LABEL[i.listing.kind] || i.listing.kind)} · v${esc(i.version)} · installed ${time(i.installed_at)}</span>${passport(i.passport, { id: null })}</div><form method="post" action="/installs/${esc(i.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke</button></form></li>`).join('')}</ul>` : empty('Nothing installed yet. Try a listing in the sandbox, then install it with its passport.', '/listings', 'Browse listings')}++</section>++<section class="ex-install-revoked" id="ex-installs-revoked" aria-labelledby="ex-installs-revoked-h">++<div class="ex-section-head"><h2 id="ex-installs-revoked-h">Revoked</h2><span class="ex-count">${revoked.length}</span></div>++${revoked.length ? `<ul class="ex-install-list">${revoked.map((i) => `<li class="ex-install is-revoked" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">v${esc(i.version)} · revoked ${time(i.revoked_at)}</span></div><span class="ex-status ex-status-revoked">revoked</span></li>`).join('')}</ul>` : '<p class="ex-muted">No installation has been revoked.</p>'}++</section>++</div>`;++}++++function offersPage({ sent = [], received = [] } = {}) {++ return `<div class="ex-wrap" id="ex-offers">++<header class="ex-page-h"><p class="ex-kicker">Free claims and swap offers</p><h1>Your offers</h1>++<p class="ex-lede">A free listing takes claims, a swap listing takes offers. Accepting one closes the listing as given or swapped; nothing is paid through Exchange.</p></header>++<section id="ex-offers-received" aria-labelledby="ex-offers-received-h">++<div class="ex-section-head"><h2 id="ex-offers-received-h">Received</h2><span class="ex-count">${received.length}</span></div>++${received.length ? `<ul class="ex-offer-list">${received.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p>${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}<span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">Nobody has claimed or offered on your listings yet.</p>'}++</section>++<section id="ex-offers-sent" aria-labelledby="ex-offers-sent-h">++<div class="ex-section-head"><h2 id="ex-offers-sent-h">Sent</h2><span class="ex-count">${sent.length}</span></div>++${sent.length ? `<ul class="ex-offer-list">${sent.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p><span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">You have not claimed or offered on a listing yet.</p>'}++</section>++</div>`;++}++++function safetyPage() {++ return `<div class="ex-wrap" id="ex-safety">++<header class="ex-page-h"><p class="ex-kicker">Safety</p><h1>Free and swap, safely</h1>++<p class="ex-lede">OpenVibe.Exchange v1 has no money in it. That removes most of the scams; it does not remove all of them.</p></header>++<p class="ex-note">${esc(SAFETY_NOTE)}</p>++<section><h2>No money, anywhere</h2><ul class="ex-prose-list">++<li>There is no price, no checkout and no Billing in v1. A listing is free or a swap.</li>++<li>Nobody should send money, a gift card, crypto or a deposit for anything here. A listing, offer or passport that asks for payment is refused when it is submitted.</li>++<li>A passport's <b>spend</b> is always nothing in v1. If a listing ever shows a spend in its passport, do not install it; report it to staff.</li>++</ul></section>++<section><h2>Read the passport</h2><p class="ex-prose">The passport says exactly what the listing can read, write and contact. The sandbox checks it, the install snapshots it, and a later edit to the listing cannot widen an install you already made. Revoke anything you no longer use.</p></section>++<section><h2>What may not be listed</h2><p class="ex-prose">The fixed prohibited-items list: weapons and ammunition, illegal drugs, malware and stolen credentials or access, sexual services or content, forged or counterfeit documents, and live animals. Staff can hide anything else that breaks the rules.</p></section>++<section><h2>For a local swap</h2><ul class="ex-prose-list">++<li>Meet in a public place, in daylight if you can, and take someone with you.</li>++<li>Check what you are swapping before you hand anything over; Exchange does not inspect or hold it.</li>++<li>Keep the arrangement on the listing where you can, and keep a copy of what you agreed.</li>++<li>Never share a password, a token or a private key to prove something works. A listing's manifest is public metadata and must never carry one.</li>++</ul></section>++<section><h2>What Exchange does not do</h2><p class="ex-prose">Exchange does not execute a listing in v1 (the sandbox is a dry run), does not hold a swap in escrow, and does not guarantee that a maker owns what they list. The licence on the listing is the maker's promise; read it before you install.</p></section>++</div>`;++}++++function developersPage() {++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;++ return `<div class="ex-wrap" id="ex-developers">++<header class="ex-page-h"><p class="ex-kicker">Developers</p><h1>The Exchange API</h1>++<p class="ex-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>exchange_at</code> session cookie or a Network user token as a Bearer.</p></header>++<section><h2>Read</h2><dl class="ex-eps">++${ep('GET', '/api/v1/listings?limit=&cursor=&mode=&kind=&q=', 'Public listings, newest first, cursor-paged (at most 50).')}++${ep('GET', '/api/v1/listings/:slug', 'One listing with its passport, manifest, licence, tests and counters. A draft/hidden/closed one is 404 unless it is yours or you are staff.')}++${ep('GET', '/api/v1/listings/:slug/passport', 'The exact passport and manifest an installer accepts.')}++${ep('GET', '/api/v1/listings/:slug/tests', 'The conformance tests for the version the listing names.')}++</dl></section>++<section><h2>Write (signed in, same-origin)</h2><dl class="ex-eps">++${ep('POST', '/api/v1/listings', '<code>{ title, kind, mode, summary, description?, wants?, license, license_url?, version, manifest: { name, entry, runtime? }, passport: { reads, writes, spends, contacts } }</code>. Saves a draft; <code>spends</code> must be empty.')}++${ep('POST', '/api/v1/listings/:slug/list', 'Publish a draft after every conformance test for its version passed.')}++${ep('POST', '/api/v1/listings/:slug/close', '<code>{ reason: "withdrawn"|"given"|"swapped" }</code>, the owner. Closes it and tombstones its Search document.')}++${ep('POST', '/api/v1/listings/:slug/sandbox', 'Run a manifest dry-run trial. A passed trial is valid for the configured window.')}++${ep('GET', '/api/v1/sandbox/:id', 'One trial of yours and its checks.')}++${ep('POST', '/api/v1/listings/:slug/install', 'Install with the passport snapshot. 409 <code>exchange.sandbox_required</code> without a passed trial for this version.')}++${ep('GET', '/api/v1/installs', 'Your installations, active and revoked.')}++${ep('POST', '/api/v1/installs/:id/revoke', 'Revoke in one tap. Idempotent.')}++${ep('POST', '/api/v1/listings/:slug/offers', '<code>{ body, give? }</code>: a free claim, or a swap offer with what you give. No money.')}++${ep('GET', '/api/v1/offers', 'The offers you sent and received.')}++${ep('POST', '/api/v1/offers/:id/accept|decline|withdraw', 'The owner accepts/declines; the sender withdraws. Accepting closes the listing as given or swapped.')}++${ep('GET', '/api/v1/me/listings', 'Every listing of yours, all statuses.')}++</dl></section>++<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="ex-eps">++${ep('POST', '/api/v1/listings/:slug/hide', 'Hide a listing and tombstone its Search document.')}++${ep('GET', '/api/v1/listings/:slug/offers', 'The open claims/offers on a listing.')}++</dl></section>++<section id="ex-search"><h2>Search</h2><p class="ex-fine">Each public listing is one <code>search.index-document@1</code> (owner <code>exchange</code>, type <code>listing</code>, canonical page <code>/listings/<slug></code>) queued in <code>exchange_outbox</code>; closing or hiding it queues a tombstone. The outbox waits for Network's <code>search.document.write</code> grant; the relay is not part of v1.</p></section>++<section id="ex-grants"><h2>Grants v1 does not have yet</h2><p class="ex-fine">The plan enforces a passport with Network grants and executes a listing in a Run sandbox. v1 does neither: an install is an Exchange record and the sandbox is a dry run. The service will ask for <code>network.grants.write</code> and <code>network.grants.revoke</code> before an install/revoke can reach Network, <code>run.sandbox.execute</code> before a trial can run code, and <code>search.document.write</code> before the outbox can reach Search. Account export and deletion need <code>network.account.export.contribute</code> and <code>network.account.deletion.confirm</code>; the two Events subscriptions need <code>events.subscription.manage</code>.</p></section>++</div>`;++}++++function signIn({ title, text, next }) {++ return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="ex-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;++}++++function notFound() {++ return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty" id="ex-not-found"><p class="ex-kicker">404</p><h1>This listing is not here</h1><p>It may have been closed, hidden by staff, or the address was never a listing.</p><a class="ex-btn" href="/listings">Browse the listings</a></div></div>`;++}++++module.exports = {++ home, listingsPage, listingPage, submitForm, sandboxPage, installsPage, offersPage, safetyPage, developersPage,++ signIn, notFound, passport, testsBlock, card, empty, kindLabel, modeBadge, statusBadge,++ KIND_LABEL, MODE_LABEL, STATUS_LABEL, CLOSED_LABEL, PASSPORT_LABEL, SAFETY_NOTE,++};nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++/**++ * ADR-033: Exchange's part of an account export (listings, installations, sandbox trials, offers) and of a deletion,++ * applied through /internal/events with a stand-in Network. A deletion anonymises and closes a public listing with++ * a Search tombstone, deletes a draft outright, and erases the person's installs, trials and offers; another++ * person's listing stays. A redelivery changes nothing; a bad signature and a proxied request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++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_exchange', 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: 'exchange', clientSecret: 'exchange-secret' });++ const t = await boot({ env: { EXCHANGE_EVENTS_SECRET: SECRET }, accountSend: sender });++ const alice = { id: 101, subject: A, username: 'alice', display_name: 'alice', role: 'user' };++ const bob = { id: 102, subject: B, username: 'bob', display_name: 'bob', role: 'user' };++ try {++ const aListed = await fixtures.listed(t, alice, { title: 'Alice\'s listed theme', kind: 'theme' });++ const aDraft = await fixtures.create(t, alice, { title: 'Alice\'s draft theme', kind: 'theme' });++ const bListed = await fixtures.listed(t, bob, { title: 'Bob\'s listed theme', kind: 'theme' });++ const sb = await fixtures.sandbox(t, alice, bListed.slug);++ const inst = await fixtures.install(t, alice, bListed.slug);++ const claim = await t.get(`/api/v1/listings/${bListed.slug}/offers`, { as: alice, json: { body: 'I would like Bob\'s theme.' } });++ assert.strictEqual(claim.status, 201);++++ await check('an export carries Alice\'s listings, trial, install and offer, and not Bob\'s', async () => {++ const body = bodyOf(exportEvent);++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_exchange');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('Alice\'s listed theme'));++ assert.ok(all.includes('Alice\'s draft theme'));++ assert.ok(!all.includes('Bob\'s listed theme'), 'nobody else\'s listing');++ assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, inst.id);++ assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, sb.trial.id);++ assert.match(all, /I would like Bob/);++ });++++ await check('a deletion closes the listed listing with a tombstone, deletes the draft and Alice\'s rows; Bob stays', async () => {++ const body = bodyOf(deleteEvent);++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ const db = t.ctx.s.db;++ const listed = await db.maybe('SELECT owner, status, closed_reason, title, summary, creator FROM exchange_listings WHERE id = $1', [aListed.id]);++ assert.strictEqual(listed.owner, null);++ assert.strictEqual(listed.status, 'closed');++ assert.strictEqual(listed.closed_reason, 'withdrawn');++ assert.strictEqual(listed.title, 'A deleted listing');++ assert.strictEqual(listed.summary, "Removed with its maker's account.");++ assert.strictEqual(listed.creator, 'a deleted maker');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE id = $1', [aDraft.id])), 0);++ const bobRow = await db.maybe('SELECT owner, status FROM exchange_listings WHERE id = $1', [bListed.id]);++ assert.strictEqual(bobRow.owner, B);++ assert.strictEqual(bobRow.status, 'listed');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_installs WHERE id = $1', [inst.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE id = $1', [sb.trial.id])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_offers WHERE id = $1', [claim.json().id])), 0);++ const tomb = await db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id]);++ assert.ok(tomb, 'a tombstone was queued');++ assert.strictEqual(tomb.payload.deleted, true);++ 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.exchange_listings_drafts >= 1);++ assert.ok(confirmation.body.retained.exchange_listings_closed >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing', async () => {++ const body = bodyOf(deleteEvent);++ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'unchanged');++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id])), 1, 'no second tombstone');++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const body = bodyOf(exportEvent);++ const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(body, SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,163 @@++'use strict';++/**++ * The JSON API: a draft is created and is not public; publishing it needs the conformance tests; the public list++ * pages with an opaque cursor and filters; detail/passport/tests shapes; validation refusals; owner and staff++ * visibility; close and hide. There is no price in any shape.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ try {++ let draft = null;++ await check('POST /listings saves a draft with its passport and tests; it is not public yet', async () => {++ draft = await fixtures.create(t, alice);++ assert.match(draft.id, /^exl_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(draft.slug, 'starter-site-template');++ assert.strictEqual(draft.status, 'draft');++ assert.strictEqual(draft.mode, 'free');++ assert.deepStrictEqual(draft.passport.spends, []);++ assert.strictEqual(draft.tests.verified, true);++ assert.strictEqual(draft.tests.count, 4);++ assert.ok(!('price' in draft));++ const pub = await t.get('/api/v1/listings');++ assert.strictEqual(pub.json().count, 0);++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);++ });++++ await check('the owner and staff see a draft; another person does not', async () => {++ const owner = await t.get(`/api/v1/listings/${draft.slug}`, { as: alice });++ assert.strictEqual(owner.status, 200);++ assert.strictEqual(owner.json().status, 'draft');++ assert.strictEqual(owner.json().owner, alice.subject);++ assert.strictEqual(owner.json().mine, true);++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`, { as: bob })).status, 404);++ const staff = await t.get(`/api/v1/listings/${draft.slug}`, { as: mod });++ assert.strictEqual(staff.status, 200);++ assert.strictEqual(staff.json().staff, true);++ });++++ await check('publishing makes it public with the passport, licence and versioned tests', async () => {++ const pub = await t.get(`/api/v1/listings/${draft.slug}/list`, { as: alice, method: 'POST' });++ assert.strictEqual(pub.status, 200, pub.text);++ assert.strictEqual(pub.json().status, 'listed');++ const list = await t.get('/api/v1/listings?mode=free&kind=template');++ assert.strictEqual(list.json().count, 1);++ assert.strictEqual(list.headers.get('cache-control'), 'public, max-age=60');++ const one = list.json().listings[0];++ assert.strictEqual(one.slug, draft.slug);++ assert.strictEqual(one.tests.verified, true);++ assert.deepStrictEqual(one.passport.reads, ['search.read']);++ const detail = await t.get(`/api/v1/listings/${draft.slug}`);++ assert.strictEqual(detail.status, 200);++ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(detail.json().license, 'CC0-1.0');++ assert.strictEqual(detail.json().manifest.entry, 'templates/starter/index.html');++ assert.strictEqual(detail.json().tests.rows.length, 4);++ const passport = await t.get(`/api/v1/listings/${draft.slug}/passport`);++ assert.strictEqual(passport.status, 200);++ assert.deepStrictEqual(passport.json().passport.writes, ['space.files.write']);++ assert.deepStrictEqual(passport.json().passport.spends, []);++ const tests = await t.get(`/api/v1/listings/${draft.slug}/tests`);++ assert.strictEqual(tests.json().verified, true);++ assert.deepStrictEqual(tests.json().tests.map((x) => x.name).sort(), ['licence', 'manifest', 'no_money', 'passport']);++ });++++ await check('the list pages with an opaque cursor and filters, and refuses a garbage cursor', async () => {++ const two = await fixtures.listed(t, alice, { title: 'A calm theme kit', kind: 'theme', mode: 'swap', wants: 'A type kit' });++ const three = await fixtures.listed(t, alice, { title: 'A surf map', kind: 'game_asset' });++ const one = await t.get('/api/v1/listings?limit=2');++ assert.strictEqual(one.json().count, 2);++ assert.ok(one.json().next_cursor);++ const next = await t.get(`/api/v1/listings?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);++ assert.strictEqual(next.status, 200);++ assert.strictEqual(next.json().count, 1);++ assert.strictEqual(next.json().next_cursor, null);++ assert.strictEqual((await t.get('/api/v1/listings?cursor=garbage')).status, 400);++ assert.strictEqual((await t.get('/api/v1/listings?kind=nope')).status, 400);++ assert.strictEqual((await t.get('/api/v1/listings?mode=paid')).status, 400);++ const swaps = await t.get('/api/v1/listings?mode=swap');++ assert.strictEqual(swaps.json().count, 1);++ assert.strictEqual(swaps.json().listings[0].slug, two.slug);++ const q = await t.get('/api/v1/listings?q=surf');++ assert.strictEqual(q.json().count, 1);++ assert.strictEqual(q.json().listings[0].slug, three.slug);++ });++++ await check('a bad listing is refused with a code, in the API and the form alike', async () => {++ const post = (body) => t.get('/api/v1/listings', { as: alice, json: body });++ const base = fixtures.body();++ assert.strictEqual((await post({ ...base, title: '' })).status, 400);++ assert.strictEqual((await post({ ...base, spends: [] })).status, 400);++ const money = await post({ ...base, summary: 'Yours for $20, cash only.' });++ assert.strictEqual(money.status, 400);++ assert.strictEqual(money.json().code, 'exchange.prohibited');++ const spend = await post({ ...base, passport: { ...base.passport, spends: ['billing.spend'] } });++ assert.strictEqual(spend.status, 400);++ assert.strictEqual(spend.json().code, 'exchange.no_money');++ const secret = await post({ ...base, manifest: { ...base.manifest, token: 'ghp_123' } });++ assert.strictEqual(secret.status, 400);++ const secretValue = await post({ ...base, manifest: { ...base.manifest, entry: 'api_key=sk-live-123' } });++ assert.strictEqual(secretValue.status, 400);++ assert.strictEqual(secretValue.json().code, 'exchange.manifest_secret');++ const freeWants = await post({ ...base, mode: 'free', wants: 'A type kit' });++ assert.strictEqual(freeWants.status, 400);++ const swapNoWants = await post({ ...base, mode: 'swap', wants: '' });++ assert.strictEqual(swapNoWants.status, 400);++ const badVersion = await post({ ...base, version: 'latest' });++ assert.strictEqual(badVersion.status, 400);++ });++++ await check('a malformed body and an unknown API route are problem+json', async () => {++ const bad = await t.get('/api/v1/listings', { as: alice, body: '{', headers: { 'content-type': 'application/json' } });++ assert.strictEqual(bad.status, 400);++ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(bad.json().code, 'request.malformed_json');++ const nope = await t.get('/api/v1/nope');++ assert.strictEqual(nope.status, 404);++ assert.strictEqual(nope.json().code, 'route.not_found');++ });++++ await check('/me/listings has every status of yours', async () => {++ const draft2 = await fixtures.create(t, alice, { title: 'A second draft' });++ const mine = await t.get('/api/v1/me/listings', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.ok(mine.json().listings.some((x) => x.slug === draft2.slug && x.status === 'draft'));++ assert.strictEqual((await t.get('/api/v1/me/listings')).status, 401);++ });++++ await check('close is the owner\'s, removes it from the public list, and is idempotent', async () => {++ const close = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: bob, method: 'POST' });++ assert.strictEqual(close.status, 404);++ const ok = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });++ assert.strictEqual(ok.status, 200);++ assert.strictEqual(ok.json().status, 'closed');++ assert.strictEqual(ok.json().closed_reason, 'withdrawn');++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);++ const again = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: {} });++ assert.strictEqual(again.status, 200);++ const pub = await t.get('/api/v1/listings?limit=50');++ assert.ok(!pub.json().listings.some((x) => x.slug === draft.slug));++ });++++ await check('staff can hide a listing; the owner still sees it', async () => {++ const l = await fixtures.listed(t, alice, { title: 'A hideable template' });++ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}/hide`, { as: alice, method: 'POST' })).status, 403);++ const hide = await t.get(`/api/v1/listings/${l.slug}/hide`, { as: mod, method: 'POST' });++ assert.strictEqual(hide.status, 200);++ assert.strictEqual(hide.json().status, 'hidden');++ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}`)).status, 404);++ const owner = await t.get(`/api/v1/listings/${l.slug}`, { as: alice });++ assert.strictEqual(owner.status, 200);++ assert.strictEqual(owner.json().status, 'hidden');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.EXCHANGE_RATE_LIMIT_PER_MIN = process.env.EXCHANGE_RATE_LIMIT_PER_MIN || '100000';++process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN = process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN || '100000';++process.env.EXCHANGE_LIMITS_MINUTE = process.env.EXCHANGE_LIMITS_MINUTE || '100000';++process.env.EXCHANGE_LIMITS_HOUR = process.env.EXCHANGE_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Exchange on a temp database with a mock Network (test/helpers/mocks.js), captures log lines, and++ * returns a small HTTP client. Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })++ * t.signIn(user) → cookie header value (exchange_at = a Network user token, as after /auth/callback)++ * 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();++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.exchange', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'exchange', OV_OAUTH_CLIENT_SECRET: 'exchange-secret', COOKIE_SECURE: 'false',++ EXCHANGE_DRILL: '1',++ EXCHANGE_EVENTS_URL: network.url,++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };++++ const config = configLib.load(env);++ const { createStore } = require('../../server/db');++ const testdb = await require('./db').testDb();++ const store = createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `exchange_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ 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;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,12 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * EXCHANGE_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.EXCHANGE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'exchange', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/** Shared fixtures: a valid CC0 template and helpers to create / publish it through the public API. */++++const BODY = Object.freeze({++ title: 'Starter site template',++ kind: 'template',++ mode: 'free',++ summary: 'A licensed, static starter site: one page, no tracking.',++ description: 'Copy it into a Space, change the words, publish.',++ license: 'CC0-1.0',++ license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',++ version: '1.0.0',++ manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },++ passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },++});++++function body(overrides = {}) {++ return {++ ...BODY,++ ...overrides,++ manifest: { ...BODY.manifest, ...(overrides.manifest || {}) },++ passport: { ...BODY.passport, ...(overrides.passport || {}) },++ };++}++++async function create(t, user, overrides = {}) {++ const r = await t.get('/api/v1/listings', { as: user, json: body(overrides) });++ if (r.status !== 201) throw new Error(`create listing ${r.status}: ${r.text}`);++ return r.json();++}++++async function listed(t, user, overrides = {}) {++ const l = await create(t, user, overrides);++ const r = await t.get(`/api/v1/listings/${l.slug}/list`, { as: user, method: 'POST' });++ if (r.status !== 200) throw new Error(`publish listing ${r.status}: ${r.text}`);++ return r.json();++}++++async function sandbox(t, user, slug) {++ const r = await t.get(`/api/v1/listings/${slug}/sandbox`, { as: user, method: 'POST' });++ if (r.status !== 201) throw new Error(`sandbox ${r.status}: ${r.text}`);++ return r.json();++}++++async function install(t, user, slug) {++ const r = await t.get(`/api/v1/listings/${slug}/install`, { as: user, method: 'POST' });++ if (r.status !== 201 && r.status !== 200) throw new Error(`install ${r.status}: ${r.text}`);++ return r.json();++}++++module.exports = { BODY, body, create, listed, sandbox, install };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 `exchange`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Exchange, 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 !== 'exchange' || body.client_secret !== 'exchange-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:exchange', 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,115 @@++'use strict';++/**++ * Sandbox, install and revoke: a public listing can be tried, a passed trial is required for the install, the++ * install keeps the passport snapshot, a revoke is one tap and idempotent, and installing again needs a fresh++ * trial. A draft cannot be tried; another person cannot read or revoke your install.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ try {++ const draft = await fixtures.create(t, alice);++ const listing = await fixtures.listed(t, alice, { title: 'A sandboxable template' });++ let trial = null;++ let installed = null;++++ await check('a draft cannot be tried; a listed one gets a passed manifest dry run', async () => {++ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}/sandbox`, { as: bob, method: 'POST' })).status, 404);++ const out = await fixtures.sandbox(t, bob, listing.slug);++ trial = out.trial;++ assert.match(trial.id, /^exs_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(trial.status, 'passed');++ assert.strictEqual(trial.checks.length, 4);++ assert.ok(trial.checks.some((c) => c.name === 'execution' && /dry run/i.test(c.detail)));++ assert.strictEqual(trial.passport.spends.length, 0);++ const page = await t.get(`/sandbox/${trial.id}`, { as: bob });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="ex-sandbox-trial"'));++ assert.ok(page.text.includes('id="ex-passport"'));++ assert.strictEqual((await t.get(`/sandbox/${trial.id}`, { as: carol })).status, 404);++ });++++ await check('an install is refused until a trial passed, then keeps the passport snapshot', async () => {++ const noTrial = await fixtures.listed(t, alice, { title: 'A no-trial template' });++ const r = await t.get(`/api/v1/listings/${noTrial.slug}/install`, { as: carol, method: 'POST' });++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json().code, 'exchange.sandbox_required');++ const out = await fixtures.install(t, bob, listing.slug);++ installed = out;++ assert.match(installed.id, /^exi_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(installed.status, 'active');++ assert.deepStrictEqual(installed.passport.reads, ['search.read']);++ assert.strictEqual(installed.listing.slug, listing.slug);++ const list = await t.get('/api/v1/installs', { as: bob });++ assert.strictEqual(list.json().count, 1);++ assert.strictEqual(list.json().installs[0].id, installed.id);++ const again = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().id, installed.id);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);++ });++++ await check('another person cannot revoke your install; a revoke is one tap and idempotent', async () => {++ assert.strictEqual((await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: carol, method: 'POST' })).status, 404);++ const r = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().status, 'revoked');++ assert.ok(r.json().revoked_at);++ const again = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().status, 'revoked');++ const list = await t.get('/api/v1/installs', { as: bob });++ assert.strictEqual(list.json().installs[0].status, 'revoked');++ assert.strictEqual((await t.get('/api/v1/installs')).status, 401);++ });++++ await check('installing again needs a fresh trial; the first one was consumed', async () => {++ const r = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json().code, 'exchange.sandbox_required');++ const fresh = await fixtures.sandbox(t, bob, listing.slug);++ assert.notStrictEqual(fresh.trial.id, trial.id);++ const reinstalled = await fixtures.install(t, bob, listing.slug);++ assert.strictEqual(reinstalled.status, 'active');++ assert.notStrictEqual(reinstalled.id, installed.id);++ const consumed = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE consumed_at IS NOT NULL');++ assert.strictEqual(Number(consumed), 2);++ });++++ await check('an expired trial cannot install', async () => {++ const l = await fixtures.listed(t, alice, { title: 'An expiring template' });++ const out = await fixtures.sandbox(t, carol, l.slug);++ await t.ctx.s.db.exec("UPDATE exchange_sandbox_trials SET created_at = now() - interval '2 hours', expires_at = now() - interval '1 minute' WHERE id = $1", [out.trial.id]);++ const r = await t.get(`/api/v1/listings/${l.slug}/install`, { as: carol, method: 'POST' });++ assert.strictEqual(r.status, 409);++ });++++ await check('a session write from another site is refused', async () => {++ const l = await fixtures.listed(t, alice, { title: 'A cross-site template' });++ const r = await t.get(`/api/v1/listings/${l.slug}/sandbox`, { as: bob, method: 'POST', origin: 'https://evil.example' });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'exchange.cross_site');++ });++++ await check('the installs page is complete without JavaScript and lists active and revoked', async () => {++ const page = await t.get('/installs', { as: bob });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="ex-installs"'));++ assert.ok(page.text.includes('id="ex-installs-active"'));++ assert.ok(page.text.includes('id="ex-installs-revoked"'));++ assert.ok(page.text.includes('ex-passport'));++ assert.ok(page.text.includes('action="/installs/'));++ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual((await t.get('/installs')).status, 401);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The expand migration applies in one pass and creates exactly the tables the plan names, plus the SDK's account++ * receipts and telemetry. The listing constraints are real: no money, a passport with an empty spend, a++ * free/swap-only mode, and one active install / one open offer per person and listing.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('0001_initial.sql applies and creates the Exchange tables', async () => {++ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);++ for (const table of ['exchange_listings', 'exchange_listing_tests', 'exchange_listing_events', 'exchange_sandbox_trials', 'exchange_installs', 'exchange_offers', 'exchange_outbox', 'account_data_events', 'telemetry_samples']) {++ assert.ok(names.includes(table), `missing ${table}`);++ }++ });++++ await check('there is no money column on a listing', async () => {++ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'exchange_listings'")).map((r) => r.column_name);++ for (const forbidden of ['price', 'price_vibes', 'currency', 'billing_intent_id', 'amount']) assert.ok(!cols.includes(forbidden), forbidden);++ assert.ok(cols.includes('passport'));++ assert.ok(cols.includes('manifest'));++ });++++ await check('a listing refuses an unknown kind and a spend in its passport', async () => {++ const db = t.ctx.s.db;++ const insert = (slug, kind, passport) => db.exec(++ `INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, manifest, passport)++ VALUES ($1, $2, 'A listing', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', $3, '{"name":"x","entry":"y"}'::jsonb, $4::jsonb)`,++ [`exl_${ids.ulid()}`, slug, kind, JSON.stringify(passport)],++ );++ await assert.rejects(insert('bad-kind', 'notakind', { reads: [], writes: [], spends: [], contacts: [] }), /exchange_listings_kind/);++ await assert.rejects(insert('bad-spend', 'template', { reads: [], writes: [], spends: ['billing.spend'], contacts: [] }), /exchange_listings_passport/);++ await insert('bad-mode', 'template', { reads: [], writes: [], spends: [], contacts: [] });++ await assert.rejects(db.exec("UPDATE exchange_listings SET mode = 'paid' WHERE slug = 'bad-mode'"), /exchange_listings_mode/);++ await insert('bad-wants', 'template', { reads: [], writes: [], spends: [], contacts: [] });++ await assert.rejects(db.exec("UPDATE exchange_listings SET wants = 'money' WHERE slug = 'bad-wants'"), /exchange_listings_wants/);++ });++++ await check('the one-active-install and one-open-offer indexes are real', async () => {++ const db = t.ctx.s.db;++ const subject = ids.newId('user');++ const at = new Date().toISOString();++ const lid = `exl_${ids.ulid()}`;++ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, status, listed_at, manifest, passport)++ VALUES ($1, 'installable', 'Installable', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', 'template', 'listed', $2, '{"name":"x","entry":"y"}'::jsonb, '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [lid, at]);++ const iid = () => `exi_${ids.ulid()}`;++ await db.exec(`INSERT INTO exchange_installs (id, listing_id, subject, version, passport) VALUES ($1, $2, $3, '1.0.0', '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [iid(), lid, subject]);++ await assert.rejects(db.exec(`INSERT INTO exchange_installs (id, listing_id, subject, version, passport) VALUES ($1, $2, $3, '1.0.0', '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [iid(), lid, subject]), /duplicate key|unique/i);++ const oid = () => `exo_${ids.ulid()}`;++ const offer = (id) => db.exec(`INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body) VALUES ($1, $2, $3, 'A person', 'claim', 'I would like this')`, [id, lid, subject]);++ await offer(oid());++ await assert.rejects(offer(oid()), /duplicate key|unique/i);++ });++++ await check('the conformance test rows are one per version and name', async () => {++ const db = t.ctx.s.db;++ const lid = await db.value("SELECT id FROM exchange_listings WHERE slug = 'installable'");++ await db.exec("INSERT INTO exchange_listing_tests (listing_id, version, name, status) VALUES ($1, '1.0.0', 'manifest', 'passed')", [lid]);++ await assert.rejects(db.exec("INSERT INTO exchange_listing_tests (listing_id, version, name, status) VALUES ($1, '1.0.0', 'manifest', 'failed')", [lid]), /duplicate key|unique/i);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * No looks in public AI text: a summary that names looks is replaced by the listing's title; an ordinary summary++ * survives. Exchange v1 has no AI-written listing copy — this is the helper used the day a generated summary or++ * sandbox line arrives, shared with Live, Community, MediaHub and Meme.++ */++const assert = require('assert');++const { cleanSummary, hasLooks } = require('../server/text/no-looks');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ await check('a summary that names looks becomes the title', async () => {++ assert.strictEqual(cleanSummary('Starter site template', 'A shirtless man in a kitchen laughs.'), 'Starter site template');++ assert.strictEqual(cleanSummary('Starter site template', 'A pretty girl shows the theme.'), 'Starter site template');++ assert.ok(hasLooks('A shirtless man in a kitchen laughs.'));++ });++++ await check('an ordinary summary survives', async () => {++ const s = cleanSummary('Starter site template', 'A static starter site with one page and no tracking.');++ assert.strictEqual(s, 'A static starter site with one page and no tracking.');++ });++++ const t = await boot();++ try {++ await check('a creator\'s own listing words are not rewritten by noLooks', async () => {++ const user = t.network.addUser('maker');++ const r = await t.get('/api/v1/listings', {++ as: user,++ json: {++ title: 'A portrait lighting theme', kind: 'theme', mode: 'free',++ summary: 'A warm portrait theme; the maker describes the lighting, not a person.',++ license: 'MIT', version: '1.0.0',++ manifest: { name: 'Portrait lighting', entry: 'theme.json', runtime: 'css' },++ passport: { reads: [], writes: [], spends: [], contacts: [] },++ },++ });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().summary, 'A warm portrait theme; the maker describes the lighting, not a person.');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * Free and swap: a free listing takes a claim, a swap listing takes an offer with what is given in return; only the++ * owner accepts or declines, only the sender withdraws; accepting closes the listing as given or swapped. No money++ * in an offer, and a prohibited item is refused.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ try {++ const free = await fixtures.listed(t, alice, { title: 'A free starter theme', kind: 'theme' });++ const swap = await fixtures.listed(t, alice, { title: 'A surf map to swap', kind: 'game_asset', mode: 'swap', wants: 'A type kit' });++ let claim = null;++++ await check('a free listing takes a claim; the owner can read it and a duplicate is refused', async () => {++ const r = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'I would use this for my first site.' } });++ assert.strictEqual(r.status, 201, r.text);++ claim = r.json();++ assert.match(claim.id, /^exo_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(claim.kind, 'claim');++ assert.strictEqual(claim.status, 'open');++ const dup = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'Again' } });++ assert.strictEqual(dup.status, 409);++ assert.strictEqual(dup.json().code, 'exchange.offer_exists');++ const own = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice, json: { body: 'Mine' } });++ assert.strictEqual(own.status, 400);++ assert.strictEqual(own.json().code, 'exchange.own_listing');++ const ownerRead = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice });++ assert.strictEqual(ownerRead.status, 200);++ assert.strictEqual(ownerRead.json().count, 1);++ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}/offers`, { as: carol })).status, 403);++ const mine = await t.get('/api/v1/offers', { as: bob });++ assert.strictEqual(mine.json().sent.length, 1);++ const owner = await t.get('/api/v1/offers', { as: alice });++ assert.strictEqual(owner.json().received.length, 1);++ });++++ await check('only the owner accepts; accepting closes the free listing as given and tombstones it', async () => {++ const notOwner = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: bob, method: 'POST' });++ assert.strictEqual(notOwner.status, 403);++ const accepted = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: alice, method: 'POST' });++ assert.strictEqual(accepted.status, 200, accepted.text);++ assert.strictEqual(accepted.json().offer.status, 'accepted');++ assert.strictEqual(accepted.json().listing.status, 'closed');++ assert.strictEqual(accepted.json().listing.closed_reason, 'given');++ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}`)).status, 404);++ const topics = (await t.ctx.s.db.many('SELECT topic FROM exchange_outbox ORDER BY created_at')).map((x) => x.topic);++ assert.ok(topics.includes('exchange.index_document.deleted'));++ });++++ await check('a swap offer must say what is given; accepting closes it as swapped', async () => {++ const noGive = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I want this' } });++ assert.strictEqual(noGive.status, 400);++ const r = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I have just the kit', give: 'A 12-column grid kit and an icon set' } });++ assert.strictEqual(r.status, 201, r.text);++ const offer = r.json();++ assert.strictEqual(offer.kind, 'swap');++ const accepted = await t.get(`/api/v1/offers/${offer.id}/accept`, { as: alice, method: 'POST' });++ assert.strictEqual(accepted.status, 200);++ assert.strictEqual(accepted.json().listing.closed_reason, 'swapped');++ });++++ await check('withdraw and decline work for the right person; a decided offer is unchanged', async () => {++ const l = await fixtures.listed(t, alice, { title: 'A withdrawable theme' });++ const r = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I would like it' } });++ const offer = r.json();++ assert.strictEqual((await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: carol, method: 'POST' })).status, 403);++ const w = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });++ assert.strictEqual(w.status, 200);++ assert.strictEqual(w.json().offer.status, 'withdrawn');++ const again = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().offer.status, 'withdrawn');++++ const r2 = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'Me instead' } });++ const o2 = r2.json();++ assert.strictEqual((await t.get(`/api/v1/offers/${o2.id}/decline`, { as: bob, method: 'POST' })).status, 403);++ const d = await t.get(`/api/v1/offers/${o2.id}/decline`, { as: alice, method: 'POST' });++ assert.strictEqual(d.status, 200);++ assert.strictEqual(d.json().offer.status, 'declined');++ });++++ await check('an offer carrying money or a prohibited item is refused', async () => {++ const l = await fixtures.listed(t, alice, { title: 'A refusal theme' });++ const money = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I will pay you $30 by paypal.' } });++ assert.strictEqual(money.status, 400);++ assert.strictEqual(money.json().code, 'exchange.prohibited');++ const weapon = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'I can swap a firearm.' } });++ assert.strictEqual(weapon.status, 400);++ assert.strictEqual(weapon.json().code, 'exchange.prohibited');++ const shortGive = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'A claim with a tiny note', give: 'ok' } });++ assert.strictEqual(shortGive.status, 400);++ assert.strictEqual(shortGive.json().code, 'exchange.invalid');++ });++++ await check('the offers page is server-rendered and lists both directions', async () => {++ const page = await t.get('/offers', { as: alice });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="ex-offers"'));++ assert.ok(page.text.includes('id="ex-offers-received"'));++ assert.ok(page.text.includes('id="ex-offers-sent"'));++ assert.strictEqual((await t.get('/offers')).status, 401);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,173 @@++'use strict';++/**++ * Server-rendered pages: the home (hero, three scenes, fresh listings), browse and filters, one listing with its++ * passport and tests, the submit form, the sandbox trial, installations, offers, safety, developers, 404, cache++ * headers, content-hashed one-comment assets, and the discovery artifacts.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++const { assetVersion } = require('../server/render/layout');++++function jsonLd(html) {++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++}++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const listing = await fixtures.listed(t, alice, {++ title: 'A page-tested template',++ summary: 'A template that the pages can show.',++ description: 'Line one.\nLine two.',++ });++ try {++ await check('home: the hero, the three scenes and a fresh listing, public with Vary: Cookie', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ for (const marker of ['id="ex-home"', 'id="ex-listings"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Trade what people make', 'A page-tested template', '/submit', '/safety']) {++ assert.ok(r.text.includes(marker), marker);++ }++ const types = jsonLd(r.text).map((x) => x['@type']);++ assert.ok(types.includes('WebSite'), types.join(','));++ assert.ok(types.includes('WebApplication'), types.join(','));++ assert.strictEqual((await t.get('/', { as: alice })).headers.get('cache-control'), 'private, no-store');++ });++++ await check('/listings filters and lists; a public listing page carries the passport and tests', async () => {++ const browse = await t.get('/listings?mode=free&kind=template');++ assert.strictEqual(browse.status, 200);++ assert.strictEqual(browse.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(browse.text.includes('id="ex-listings-page"'));++ assert.ok(browse.text.includes('id="ex-filters"'));++ assert.ok(browse.text.includes('A page-tested template'));++ const detail = await t.get(`/listings/${listing.slug}`);++ assert.strictEqual(detail.status, 200);++ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');++ for (const marker of ['id="ex-listing"', 'id="ex-about"', 'id="ex-passport"', 'id="ex-tests"', 'id="ex-sandbox"', 'id="ex-install"', 'id="ex-manifest"', 'ex-passport-reads', 'CC0-1.0', 'Line one.']) {++ assert.ok(detail.text.includes(marker), marker);++ }++ assert.ok(!detail.text.includes('id="ex-offer-form"'), 'a guest gets a sign-in prompt, not the form');++ const signed = await t.get(`/listings/${listing.slug}`, { as: bob });++ assert.strictEqual(signed.status, 200);++ assert.ok(signed.text.includes('id="ex-offer-form"'));++ assert.ok(signed.text.includes('Run the sandbox'));++ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');++ });++++ await check('the submit form is plain HTML; a form POST saves a draft and redirects to it', async () => {++ const form = await t.get('/submit');++ assert.strictEqual(form.status, 200);++ for (const marker of ['id="ex-submit"', 'id="ex-listing-form"', 'name="passport_reads"', 'name="manifest_entry"', 'name="mode"', 'nothing', 'no money']) assert.ok(form.text.toLowerCase().includes(marker.toLowerCase()), marker);++ const r = await t.get('/submit', {++ as: alice,++ form: {++ title: 'A form-made theme', kind: 'theme', mode: 'free', summary: 'A theme made through the form.',++ description: 'It has a description.', license: 'MIT', version: '1.0.0',++ manifest_name: 'Form theme', manifest_entry: 'theme.json', manifest_runtime: 'css',++ passport_reads: 'search.read\nspace.files.read', passport_writes: 'space.files.write', passport_contacts: 'https://openvibe.space',++ },++ });++ assert.strictEqual(r.status, 303, r.text);++ assert.match(r.headers.get('location'), /^\/listings\/a-form-made-theme\?submitted=1$/);++ const page = await t.get(r.headers.get('location'), { as: alice });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="ex-owner"'));++ assert.ok(page.text.includes('Draft'));++ assert.ok(page.text.includes('id="ex-passport"'));++ assert.strictEqual((await t.get('/submit', { as: alice, form: { title: 'x' } })).status, 400);++ });++++ await check('a form POST from another site is refused', async () => {++ const r = await t.get('/submit', {++ as: alice, origin: 'https://evil.example',++ form: { title: 'A cross-site theme', kind: 'theme', mode: 'free', summary: 'A theme.', license: 'MIT', version: '1.0.0', manifest_name: 'X', manifest_entry: 'x' },++ });++ assert.strictEqual(r.status, 403);++ assert.ok(r.text.includes('id="ex-not-found"') || r.text.includes('That came from somewhere else'));++ });++++ await check('the sandbox form redirects to a stable trial page; install and revoke work by form', async () => {++ const sb = await t.get(`/listings/${listing.slug}/sandbox`, { as: bob, method: 'POST' });++ assert.strictEqual(sb.status, 303);++ assert.match(sb.headers.get('location'), /^\/sandbox\/exs_/);++ const page = await t.get(sb.headers.get('location'), { as: bob });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="ex-sandbox-trial"'));++ assert.ok(page.text.includes('The dry run passed'));++ const ins = await t.get(`/listings/${listing.slug}/install`, { as: bob, method: 'POST' });++ assert.strictEqual(ins.status, 303);++ assert.strictEqual(ins.headers.get('location'), '/installs?installed=1');++ const installs = await t.get('/installs', { as: bob });++ assert.ok(installs.text.includes('id="ex-installs"'));++ assert.ok(installs.text.includes('Revoke'));++ const id = await t.ctx.s.db.value("SELECT id FROM exchange_installs WHERE status = 'active' LIMIT 1");++ const rev = await t.get(`/installs/${id}/revoke`, { as: bob, method: 'POST' });++ assert.strictEqual(rev.status, 303);++ assert.strictEqual(rev.headers.get('location'), '/installs?revoked=1');++ assert.strictEqual((await t.ctx.s.db.value('SELECT status FROM exchange_installs WHERE id = $1', [id])), 'revoked');++ });++++ await check('the claim form creates a free claim; the owner sees it on the listing page', async () => {++ const claim = await t.get(`/listings/${listing.slug}/offers`, { as: bob, form: { body: 'I would like to use this.' } });++ assert.strictEqual(claim.status, 303);++ assert.strictEqual(claim.headers.get('location'), `/listings/${listing.slug}?offered=1`);++ const owner = await t.get(`/listings/${listing.slug}`, { as: alice });++ assert.ok(owner.text.includes('id="ex-offers"'));++ assert.ok(owner.text.includes('I would like to use this.'));++ assert.ok(owner.text.includes('Accept the claim'));++ });++++ await check('safety, developers, 404 and the submit sign-in prompt', async () => {++ const safety = await t.get('/safety');++ assert.strictEqual(safety.status, 200);++ assert.ok(safety.text.includes('id="ex-safety"'));++ assert.ok(safety.text.includes('No money, anywhere'));++ const dev = await t.get('/developers');++ assert.strictEqual(dev.status, 200);++ assert.ok(dev.text.includes('id="ex-developers"'));++ assert.ok(dev.text.includes('/api/v1/listings'));++ assert.ok(dev.text.includes('network.grants.write'));++ const nf = await t.get('/no/such/page');++ assert.strictEqual(nf.status, 404);++ assert.match(nf.headers.get('content-type'), /^text\/html/);++ assert.ok(nf.text.includes('id="ex-not-found"'));++ const prompt = await t.get('/submit', { as: null });++ assert.ok(prompt.text.includes('id="ex-listing-form"'), 'a guest can read the form');++ const install = await t.get('/installs');++ assert.strictEqual(install.status, 401);++ assert.ok(install.text.includes('Sign in with OpenVibe'));++ });++++ await check('assets are content-hashed and served; robots and sitemap list public listings', async () => {++ const v = assetVersion('css/exchange.css');++ const css = await t.get(`/css/exchange.css?v=${v}`);++ assert.strictEqual(css.status, 200);++ assert.strictEqual(css.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ assert.match(css.text, /OpenVibe\.Exchange: the room's own pieces/, 'the designed stylesheet');++ const wrong = await t.get('/css/exchange.css?v=deadbeefdeadbeef');++ assert.strictEqual(wrong.status, 200);++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');++ const js = await t.get('/js/exchange.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ assert.strictEqual(js.text.trim().split('\n').length, 1);++ const robots = await t.get('/robots.txt');++ assert.match(robots.text, /Disallow: \/api\//);++ const map = await t.get('/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.ok(map.text.includes(`https://openvibe.exchange/listings/${listing.slug}`));++ const llms = await t.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.ok(llms.text.includes('OpenVibe.Exchange'));++ const full = await t.get('/llms-full.txt');++ assert.ok(full.text.includes(listing.slug));++ });++ } finally {++ await t.close();++ }++ 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 and an++ * in-process mock of OpenVibe.Network; none needs the network.++ *++ * npm test # everything++ * npm test -- installs # 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,44 @@++'use strict';++/**++ * The free/swap safety rules: the fixed prohibited-items list, the no-money rule, and cleanText. A legitimate++ * phrase such as "a cash register icon pack" is not refused; a price or a payment request is.++ */++const assert = require('assert');++const { findProhibited, refusals, cleanText, SAFETY, PROHIBITED, MONEY_AMOUNT } = require('../server/safety');++const { check, done } = require('./helpers/boot');++++(async () => {++ await check('the prohibited list catches the plain cases', async () => {++ assert.strictEqual(findProhibited('A shotgun for swap').code, 'weapons');++ assert.strictEqual(findProhibited('Cocaine, cheap').code, 'drugs');++ assert.strictEqual(findProhibited('A ransomware builder').code, 'malware');++ assert.strictEqual(findProhibited('Escort services').code, 'sexual');++ assert.strictEqual(findProhibited('A forged passport').code, 'forged');++ assert.strictEqual(findProhibited('A kitten').code, 'animals');++ assert.strictEqual(findProhibited('Yours for $20').code, 'money');++ assert.strictEqual(findProhibited('Pay by PayPal').code, 'money');++ assert.ok(PROHIBITED.length >= 6);++ assert.ok(SAFETY.includes('no payments') || SAFETY.includes('has no payments'));++ });++++ await check('ordinary maker copy is not refused', async () => {++ assert.strictEqual(findProhibited('A cash register icon pack for a shop theme'), null);++ assert.strictEqual(findProhibited('A 12-column grid and a type kit'), null);++ assert.strictEqual(findProhibited('A surf map with three ramps'), null);++ assert.strictEqual(findProhibited(''), null);++ assert.ok(MONEY_AMOUNT.test('€15'));++ assert.ok(!MONEY_AMOUNT.test('version 15'));++ });++++ await check('refusals carry the API code, and cleanText normalises plain text', async () => {++ const hit = refusals('A theme', 'and a firearm');++ assert.strictEqual(hit.status, 400);++ assert.strictEqual(hit.code, 'exchange.prohibited');++ assert.ok(hit.message.includes('weapons'));++ assert.strictEqual(refusals('A theme'), null);++ assert.strictEqual(cleanText(' a\r\nb\u0000 ', 20), 'a\nb');++ assert.strictEqual(cleanText('abcdef', 3), 'abc');++ });++++ done();++})();nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * The Search Fabric document: owner exchange, type listing, id exl_…, canonical /listings/<slug>, title/summary,++ * facets kind/mode/licence/creator and the passport permissions, authorship human, the listing's revision. A draft++ * writes nothing; publishing upserts; closing or hiding writes a valid tombstone.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const fixtures = require('./helpers/fixtures');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ try {++ const l = await fixtures.listed(t, alice, { title: 'A searchable template' });++++ await check('publishing writes a valid search.index-document@1', async () => {++ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted' ORDER BY created_at DESC LIMIT 1");++ assert.ok(row, 'an upsert row exists');++ const doc = row.payload;++ const v = contracts.validate('search.index-document@1', doc);++ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));++ assert.strictEqual(doc.owner, 'exchange');++ assert.strictEqual(doc.type, 'listing');++ assert.strictEqual(doc.id, l.id);++ assert.strictEqual(doc.revision, l.revision);++ assert.strictEqual(doc.deleted, false);++ assert.strictEqual(doc.visibility, 'public');++ assert.strictEqual(doc.publication_state, 'published');++ assert.strictEqual(doc.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);++ assert.strictEqual(doc.title, 'A searchable template');++ assert.strictEqual(doc.facets.kind, 'template');++ assert.strictEqual(doc.facets.mode, 'free');++ assert.strictEqual(doc.facets.license, 'CC0-1.0');++ assert.deepStrictEqual(doc.facets.can_read, ['search.read']);++ assert.strictEqual(doc.authorship, 'human');++ assert.ok(doc.body.includes('search.read'));++ assert.ok(doc.body.includes('Licence: CC0-1.0'));++ });++++ await check('a draft writes no document; closing sends a valid tombstone with the next revision', async () => {++ const draft = await fixtures.create(t, alice, { title: 'A never-listed theme' });++ assert.strictEqual((await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE payload->>\'id\' = $1', [draft.id])), 0);++ const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });++ assert.strictEqual(r.status, 200);++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' ORDER BY created_at DESC LIMIT 1");++ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));++ assert.strictEqual(tomb.payload.deleted, true);++ assert.strictEqual(tomb.payload.id, l.id);++ assert.strictEqual(tomb.payload.revision, r.json().revision);++ });++++ await check('a staff hide also writes a tombstone and the public list stays empty', async () => {++ const l2 = await fixtures.listed(t, alice, { title: 'A hideable search theme' });++ const r = await t.get(`/api/v1/listings/${l2.slug}/hide`, { as: mod, method: 'POST' });++ assert.strictEqual(r.status, 200);++ const row = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [l2.id]);++ assert.ok(row);++ assert.strictEqual(row.payload.revision, r.json().revision);++ const pub = await t.get('/api/v1/listings?limit=50');++ assert.strictEqual(pub.json().count, 0);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * The first proof: a licensed template listed, tried in the sandbox, installed with its passport's grants and++ * revoked cleanly; a second run changes nothing. The seed uses the same store functions as the API and pages.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { runSeed, SLUG, LICENSE } = require('../scripts/seed-proof');++++(async () => {++ const t = await boot();++ try {++ let out = null;++ await check('the seed lists a CC0 template, tries it, installs and revokes it', async () => {++ out = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });++ assert.strictEqual(out.created, true);++ assert.strictEqual(out.listing.slug, SLUG);++ assert.strictEqual(out.listing.status, 'listed');++ assert.strictEqual(out.listing.mode, 'free');++ assert.strictEqual(out.listing.license, LICENSE);++ assert.strictEqual(out.listing.tests.verified, true);++ assert.strictEqual(out.trial.status, 'passed');++ assert.deepStrictEqual(out.install.passport.reads, ['search.read']);++ assert.strictEqual(out.revoked.status, 'revoked');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listing_tests WHERE listing_id = $1 AND status = \'passed\'', [out.listing.id])), 4);++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted'")), 1);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);++ });++++ await check('the public page and API show it; a second run adds nothing', async () => {++ assert.strictEqual((await t.get(`/listings/${SLUG}`)).status, 200);++ const api = await t.get(`/api/v1/listings/${SLUG}`);++ assert.strictEqual(api.status, 200);++ assert.strictEqual(api.json().license, LICENSE);++ const again = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });++ assert.strictEqual(again.created, false);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,147 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id exchange, cookies exchange_at/exchange_rt/exchange_oauth), the session-token++ * rules (a FedCM assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback++ * metrics, pages without JavaScript, and cross-site sign-out.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { sanitizeNext } = require('../server/auth/sso');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++++ await check('login redirects to Network with state and an S256 code challenge, client id exchange', async () => {++ const r = await t.get('/auth/login?next=/listings');++ 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'), 'exchange');++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);++ assert.match(r.headers.get('set-cookie'), /exchange_oauth=.*Path=\/auth.*HttpOnly/i);++ });++++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {++ const login = await t.get('/auth/login?next=/listings');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(r.status, 302, r.text);++ assert.strictEqual(r.headers.get('location'), '/listings');++ const set = r.headers.get('set-cookie');++ assert.match(set, /exchange_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /exchange_rt=[^;]+;.*HttpOnly/i);++ assert.match(set, /exchange_oauth=;/);++ const at = set.match(/exchange_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `exchange_at=${at}` });++ assert.strictEqual(me.json().user.username, 'kim');++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');++ });++++ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).status, 400);++ 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, 400);++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 400);++ });++++ await check('next= only accepts same-site paths', async () => {++ const r = await t.get('/auth/login?next=//evil.example/x');++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/exchange_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://openvibe.exchange' };++ assert.strictEqual(sanitizeNext('/listings?x=1', config), '/listings?x=1');++ assert.strictEqual(sanitizeNext('https://openvibe.exchange/listings/x?y=1', config), '/listings/x?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ });++++ 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, 'exchange');++ 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 ['/', '/listings', '/safety', '/developers', '/submit']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript><nav/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {++ const ping = await t.get('/api/v1/ping');++ assert.strictEqual(ping.status, 200);++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'exchange' });++ const gone = await t.get('/api/v1/nope');++ assert.strictEqual(gone.status, 404);++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(gone.json().code, 'route.not_found');++ });++++ await check('a FedCM assertion and an app token are not sessions', async () => {++ const now = Math.floor(Date.now() / 1000);++ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });++ assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `exchange_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: `exchange_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: 'exchange_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_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(!/exchange_at=;|exchange_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=/listings', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/listings');++ assert.match(String(same.headers.get('set-cookie') || ''), /exchange_at=;/);++ });++++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+# OpenVibe.Exchange — environment (production: /etc/openvibe/exchange.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5150
+HOST=127.0.0.1
+BASE_URL=https://openvibe.exchange
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:exchange:
+# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
+#EXCHANGE_LIMITS_MINUTE=120
+#EXCHANGE_LIMITS_HOUR=3000
+#EXCHANGE_API_RATE_LIMIT_PER_MIN=240
+#EXCHANGE_RATE_LIMIT_PER_MIN=300
+
+# OpenVibe.Network: SSO (OAuth client `exchange`, PKCE S256) and its JWKS; app tokens for audience
+# openvibe.exchange (no v1 route needs one).
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OV_NETWORK_ISSUER=https://openvibe.network
+OV_OAUTH_CLIENT_ID=exchange
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.exchange/auth/callback
+# EXCHANGE_AUDIENCE=openvibe.exchange
+# OV_SESSION_AUDIENCE=openvibe.network
+# EXCHANGE_PGLITE_DIR=./data/pglite
+
+# The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and is
+# required (once) before an install; a person gets at most this many trials a day.
+#EXCHANGE_SANDBOX_TTL_MINUTES=60
+#EXCHANGE_SANDBOX_PER_DAY=20
+
+# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
+# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
+EXCHANGE_EVENTS_SECRET=
+EXCHANGE_EVENTS_URL=
+#EXCHANGE_EVENTS_ENDPOINT=http://127.0.0.1:5150/internal/events
+#EXCHANGE_EVENTS_INTERVAL_MS=5000
+
+# A restore drill starts no subscriptions and creates no Events subscription.
+# EXCHANGE_DRILL=0
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test 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,42 @@
@@ -0,0 +1,42 @@
+# Changelog
+
+What changed in OpenVibe.Exchange, newest first.
+
+## 0.1.0 — 2026-10-28
+
+The first version: trade what people make, free and swap only.
+
+- **Listings.** `exchange_listings` with a unique slug, kind (`agent`, `mcp_tool`, `template`, `theme`, `game_asset`,
+ `service`), free/swap mode, SPDX licence, semantic version, public manifest, permission passport and a revision
+ that goes up on every change. There is no price, currency or Billing column; a passport's `spends` is `[]` and a
+ table check, the form and the API all enforce it.
+- **The passport.** Reads, writes, spends and contacts, declared in the manifest and shown before and after install.
+ An install stores its own snapshot, so a later edit cannot widen an old install. A manifest that looks like a
+ credential is refused.
+- **Conformance tests.** Submitting a draft runs `manifest`, `passport`, `licence` and `no_money` for its version and
+ stores one row each. Only the owner can publish, and only when all four passed.
+- **Sandbox.** A trial is a manifest/passport dry run with four recorded checks, a 60-minute TTL and at most 20 a
+ day. A passed trial is consumed by the install. v1 does not execute the listing's code; a real Run sandbox is the
+ next version's job and the page says so.
+- **Install and revoke.** `exchange_installs` with one active install per (listing, person), the passport snapshot,
+ a version and a revision. Revoke is one tap, idempotent, and a fresh trial is needed to install again.
+- **Free and swap.** A free listing takes claims (`exchange_offers` kind `claim`); a swap listing says what it wants
+ in return and takes offers with a required `give`. The owner accepts or declines; the sender withdraws. Accepting
+ closes the listing as `given` or `swapped`, declines the other open offers and tombstones its Search document.
+- **Safety.** The fixed prohibited-items and no-money filter is applied to every listing and offer, in the form and
+ the API alike; `/safety` carries the local-swap rules.
+- **Search.** Each public listing is one `search.index-document@1` (owner `exchange`, type `listing`,
+ `/listings/<slug>`) queued in `exchange_outbox` in the same transaction; closing or hiding writes a tombstone. The
+ relay waits for the `search.document.write` grant.
+- **Account data.** Listings, installs, sandbox trials and offers are exported and erased with an account. A deleted
+ listing has its words replaced, is closed and is tombstoned in Search; a draft is deleted outright; other people's
+ listings stay.
+- **The service.** Network sign-in as OAuth client `exchange` (PKCE S256, `exchange_at`/`exchange_rt`/
+ `exchange_oauth`), truthful readiness, Prometheus metrics on loopback, discovery, deploy files, and
+ server-rendered pages with stable `ex-` ids. `public/css/exchange.css` and `public/js/exchange.js` are one-comment
+ placeholders for the main session.
+- **The first proof.** `scripts/seed-proof.js` lists a CC0-1.0 Website template, runs its conformance tests, tries it
+ in the sandbox, installs it with its passport and revokes that install; it is idempotent.
+- **Tests.** Migration and constraints, the API, sandbox/install/revoke, free/swap offers, pages and cache, SSO,
+ Search, account export/deletion, noLooks, safety and the first proof — each file in its own process on a temporary
+ PGlite database with a mock Network.
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,196 @@
@@ -0,0 +1,196 @@
+# OpenVibe.Exchange
+
+> Trade what people make.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `openvibe.exchange` · **Port:** 5150 · **Service id:** `exchange` · **Env prefix:** `EXCHANGE`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Exchange is the OpenVibe marketplace for agents, MCP tools, templates (Website, Report, Space), design
+themes and kits, game assets and creator services. v1 is **free and swap only**: every listing shows a permission
+passport, can be tried in a sandbox, is installed with the passport snapshot, and is revoked in one tap. There is no
+money anywhere in v1 — no price, no Billing, no Vibes, no cash — and a passport may not declare a spend.
+
+## Purpose
+
+- **Listings with passports.** A listing names its kind, mode (`free` or `swap`), licence (SPDX), version and
+ manifest. Its passport declares exactly what it can read, write, spend and contact. `spends` is always empty in v1,
+ enforced by the form, the API and the table's check constraint.
+- **Conformance tests for this version.** Submitting a listing runs four tests — `manifest`, `passport`, `licence`,
+ `no_money` — and stores one row per version. Only the owner can publish a draft, and only when every test passed.
+- **Try before install.** A sandbox trial is a manifest and passport dry run. It records its checks and gives a
+ one-hour pass; a passed trial is consumed by the install. v1 **does not execute a listing's code**: Run has no
+ sandbox today, and the page, the API and the tests say so.
+- **Install and revoke.** An install keeps the exact passport it was made with, so a later listing edit cannot widen
+ an old install. A revoke marks it revoked on the spot and is idempotent. In v1 the Exchange install record is the
+ entitlement; the Network grant write/revoke is listed under "Grants it will need".
+- **Free and swap.** A free listing takes claims; a swap listing says what it wants in return and takes offers with
+ what the other person gives. Accepting one closes the listing as `given` or `swapped`, declines the other open
+ offers and tombstone its Search document. There is no escrow and no payment.
+- **Safety.** A fixed prohibited-items list (weapons, illegal drugs, malware/stolen access, sexual services or
+ content, forged documents, live animals, money) is applied to every listing and offer. The safety page carries the
+ local-swap rules.
+- **No AI.** v1 writes no model-generated text and holds no AI capability. `server/text/no-looks.js` is the shared
+ `noLooks` helper (the same rule as Live, Community, MediaHub and Meme) for the day a generated summary or sandbox
+ line is added; a maker's own words are never rewritten.
+
+## Owns
+
+| Table | Holds | Migration |
+|---|---|---|
+| `exchange_listings` | `exl_` + ULID, unique slug (`/listings/<slug>`), title, kind `agent\|mcp_tool\|template\|theme\|game_asset\|service`, mode `free\|swap`, summary/description/wants, SPDX licence + URL, version, public creator name, owner `usr_…`, status `draft\|listed\|hidden\|closed`, manifest jsonb, passport jsonb (spends must be `[]`), revision, listed/closed timestamps | `migrations/0001_initial.sql` |
+| `exchange_listing_tests` | one conformance test row per (listing, version, name): `manifest\|passport\|licence\|no_money`, `passed\|failed`, detail, ran_at | `0001_initial.sql` |
+| `exchange_listing_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |
+| `exchange_sandbox_trials` | `exs_` + ULID, listing, subject, version, `passed\|failed`, passport snapshot, checks jsonb, created/expires/consumed | `0001_initial.sql` |
+| `exchange_installs` | `exi_` + ULID, listing, subject, version, passport snapshot, `active\|revoked`, installed/revoked, revision; one active per (listing, subject) | `0001_initial.sql` |
+| `exchange_offers` | `exo_` + ULID, listing, from subject and display name, `claim\|swap`, body, give, `open\|accepted\|declined\|withdrawn`, timestamps; one open per (listing, sender) | `0001_initial.sql` |
+| `exchange_outbox` | the Search document queue (`evt_` id, topic, payload, attempts, lease) | `0001_initial.sql` |
+| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
+| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
+
+There is deliberately **no price, currency, amount or Billing id column**. v2 adds paid listings through Billing;
+v1 cannot represent one.
+
+## Does not own
+
+- **Identity, roles and grants** — OpenVibe.Network. Exchange verifies session tokens against its JWKS; a staff route
+ asks for `global_mod` or above. A passport is enforced by Network grants in the plan; v1 cannot create one (see
+ "Grants it will need").
+- **The sandbox runtime** — OpenVibe.Run. v1's sandbox is a dry run; a real execution needs a Run capability.
+- **Search** — OpenVibe.Search. Exchange produces `search.index-document@1` documents in its own outbox; the relay
+ is not wired because `exchange` has no `search.document.write` grant yet.
+- **Billing, transfers, cashouts** — OpenVibe.Billing. Not used at all in v1.
+- **The listings engine and local safety rules** — OpenVibe.Rent. Exchange borrows the shape of the rules (no street
+ addresses, a fixed prohibited list, staff hide) but has its own table; it does not call Rent.
+- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
+
+## Depends on
+
+Environment variable names are in [.env.example](.env.example); no value is committed.
+
+- **OpenVibe.Network** — sign-in (OAuth client `exchange`, PKCE S256, cookies
+ `exchange_at`/`exchange_rt`/`exchange_oauth`), JWKS, token exchange/refresh/revoke, account export/deletion, staff
+ roles: `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`,
+ `OV_OAUTH_CLIENT_SECRET`, `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `EXCHANGE_AUDIENCE`.
+- **OpenVibe.Events** — account deliveries: `EXCHANGE_EVENTS_SECRET`, `EXCHANGE_EVENTS_URL`,
+ `EXCHANGE_EVENTS_ENDPOINT`.
+- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
+ `EXCHANGE_PGLITE_DIR` for development.
+- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).
+- **Packages** — `openvibe-contracts` v0.229.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same tags
+ OpenVibe.Meme pins.
+
+## API
+
+Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the
+`exchange_at` session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network
+JWKS. A write made with the session cookie must come from `https://openvibe.exchange`
+(403 `exchange.cross_site`). A signed-in account also gets per-actor minute/hour limits (`EXCHANGE_LIMITS_MINUTE`,
+`EXCHANGE_LIMITS_HOUR`).
+
+| Route | What it does |
+|---|---|
+| `GET /listings?limit=&cursor=&mode=&kind=&q=` | public listings, newest first; opaque keyset cursor; `Cache-Control: public, max-age=60` |
+| `GET /listings/:slug` | one listing with passport, manifest, licence, tests and counters; a draft/hidden/closed one is 404 unless it is yours or you are staff |
+| `GET /listings/:slug/passport` | the exact passport, manifest, licence, version and tests an installer accepts |
+| `GET /listings/:slug/tests` | the conformance tests for the version the listing names |
+| `GET /listings/:slug/offers` | owner or staff: the open claims/offers |
+| `GET /me/listings` | every listing of yours, all statuses |
+| `POST /listings` | signed in: create a draft; `spends` must be empty and a credential-looking manifest is refused |
+| `POST /listings/:slug/list` | owner: publish after every conformance test for the version passed |
+| `POST /listings/:slug/close` | owner: `{ reason: "withdrawn"\|"given"\|"swapped" }`; tombstones the Search document |
+| `POST /listings/:slug/sandbox` | signed in: run a manifest dry run; a passed trial is consumed by an install |
+| `GET /sandbox/:id` | your trial and its checks |
+| `POST /listings/:slug/install` | signed in: install with the passport snapshot; 409 `exchange.sandbox_required` without a fresh passed trial |
+| `GET /installs` | your installations, active and revoked |
+| `POST /installs/:id/revoke` | revoke in one tap; idempotent |
+| `POST /listings/:slug/offers` | signed in: `{ body, give? }` — a claim on a free listing or a swap offer with what you give |
+| `GET /offers` | the offers you sent and received |
+| `POST /offers/:id/accept\|decline\|withdraw` | owner accepts/declines; sender withdraws; accepting closes the listing |
+| `POST /listings/:slug/hide` | staff: hide a listing and tombstone it |
+
+Validation refusals are `400 { code: "exchange.invalid" }`. Other codes: `exchange.no_money`,
+`exchange.prohibited`, `exchange.manifest_secret`, `exchange.sandbox_required`, `exchange.sandbox_limit`,
+`exchange.offer_exists`, `exchange.own_listing`, `exchange.owner_only`, `exchange.cross_site`,
+`exchange.sign_in_required`, `exchange.staff_required`, `exchange.not_found`, `exchange.rate_limited`.
+
+## The permission passport
+
+A passport is public, declarative metadata:
+
+```json
+{
+ "reads": ["search.read", "space.files.read"],
+ "writes": ["space.files.write"],
+ "spends": [],
+ "contacts": ["https://openvibe.space"]
+}
+```
+
+- Reads and writes are lower-case permission names; contacts are `https` origins; spends must be empty in v1.
+- The manifest is `{ "name", "entry", "runtime" }` only. It is public metadata and never a credential: an unknown
+ key or a value that looks like a secret is refused (`exchange.manifest_secret`).
+- The listing page shows the passport before and after install; an install stores its own copy.
+
+## The sandbox
+
+`POST /listings/:slug/sandbox` runs four checks: the listing is public, every conformance test for this version
+passed, the passport declares no spend, and the execution step is a dry run. The trial is `passed` when all four do;
+it expires after `EXCHANGE_SANDBOX_TTL_MINUTES` (60 by default); a person may run at most
+`EXCHANGE_SANDBOX_PER_DAY` (20) trials a day. A passed trial is **consumed** by the install, so installing again
+needs a fresh one.
+
+This is deliberately honest: v1 does not run a listing's code. A real sandbox needs Run's new capability (below).
+
+## Search
+
+Each public listing is one `search.index-document@1` (owner `exchange`, type `listing`, canonical page
+`/listings/<slug>`) queued in `exchange_outbox` in the same transaction as the listing change. A draft writes
+nothing; closing or hiding writes a tombstone. The outbox waits for `search.document.write`; the relay is not part of
+v1.
+
+## Deploy
+
+`deploy/systemd/openvibe-exchange.service` (port 5150, `/opt/openvibe.exchange`,
+`/etc/openvibe/exchange.env`) and `deploy/nginx/openvibe.exchange.conf` (TLS, www redirect, `/auth/me` on the API
+zone, sign-in, API and a forms zone, `/metrics` and `/internal` 404 from outside). No proxy cache: the app decides
+each response's cacheability.
+
+`npm run seed:proof` creates the first proof through the same store functions as the API: a CC0-1.0 Website template
+listed, tried in the sandbox, installed with its passport and revoked. It is idempotent.
+
+## Grants it will need
+
+v1 is written so each of these is a single call site; none is used today. They are external grants on other
+services, requested from the main session (this service registers no capability of its own).
+
+| Grant / scope | Service | Used for | State |
+|---|---|---|---|
+| `network.grants.write` | OpenVibe.Network | create the developer-app grant from the passport snapshot at install | **proposed**; not in contracts v0.229.0 yet |
+| `network.grants.revoke` | OpenVibe.Network | revoke that grant everywhere at one-tap revoke | **proposed**; not in contracts v0.229.0 yet |
+| `run.sandbox.execute` | OpenVibe.Run | execute a listing's entry point in a real sandbox and run its own tests | **proposed**; Run has no sandbox today. Existing `run.job.submit` could back it if Run adds a sandbox job class |
+| `search.document.write` | OpenVibe.Search | relay the `exchange_outbox` documents | existing capability, not granted |
+| `network.account.export.contribute` | OpenVibe.Network | push a person's listings/installs/trials/offers export | existing capability, not granted |
+| `network.account.deletion.confirm` | OpenVibe.Network | confirm the erasure and counts | existing capability, not granted |
+| `events.subscription.manage` | OpenVibe.Events | create the two account subscriptions | existing scope, client credentials |
+| `billing.intent.create`, `billing.transfer.create`, `billing.entitlement.check`, `billing.cashout.manage`, `billing.usage.record` | OpenVibe.Billing | **v2 only**: paid listings, transfers, cashouts | not used in v1; no money |
+
+No paid provider and no AI capability is used. No secret is stored in the database, rendered into a page or logged.
+
+## Tests
+
+`test/` boots the app on a temporary PGlite database with an in-process mock of OpenVibe.Network. The suite covers
+migration and constraints, the API and validation, sandbox/install/revoke, free/swap offers, server-rendered pages
+and cache headers, SSO/PKCE and readiness, the Search document, account export/deletion, noLooks and the safety list,
+and the first proof.
+
+```sh
+npm test # every file, each in its own process
+OV_ALLOW_SUITE=1 npm test
+npm run test:pg # the same files through PostgreSQL + PgBouncer containers
+VERBOSE=1 npm test # also print each file's output
+```
+
+## License
+
+AGPL-3.0-only. See [LICENSE](LICENSE).
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+# 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 listings.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.exchange, openvibe.meme, 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.Exchange holds
+
+- Public listings: the title, summary, description, wants, kind, mode, licence, version, public creator name and
+ revision, plus the manifest (`name`, `entry`, `runtime`) and the permission passport. None of it is a credential.
+- Conformance tests for each listing version and the status events of a listing.
+- A person's sandbox trials, installations and the claims/offers they made or received. These are exported and
+ erased with an account, along with the person's listings.
+- Network session tokens only in httpOnly cookies (`exchange_at`, `exchange_rt`); no token is stored in the
+ database, rendered into a page or logged.
+- **No money**: there is no price, no Billing id and no payment credential anywhere in v1.
+
+A manifest is public metadata. A key or value that looks like a secret (`api_key=`, `token:`, a private key block)
+is refused before it is stored, and an unknown manifest key is refused outright. A listing's passport spends must be
+empty; a spend is refused by the form, the API and a table check.
+
+v1 does not execute a listing's code. The sandbox is a manifest/passport dry run, and it says so on the page and in
+the API. A real sandbox needs a Run capability and is not part of this version.
+
+The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
+`/internal` and `/metrics`.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+{
+ "repository": "OpenVibers/OpenVibe.Exchange",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Exchange: trade what people make — free and swap listings with a permission passport, conformance tests for each version, a sandbox, install with the passport snapshot and one-tap revoke",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5150",
+ "domain": "openvibe.exchange",
+ "contracts": "openvibe-contracts v0.229.0",
+ "sdk": "openvibe-sdk v0.70.0",
+ "shared": "openvibe-shared v4.31.0",
+ "packages": [
+ "openvibe-contracts v0.229.0",
+ "openvibe-sdk v0.70.0",
+ "openvibe-shared v4.31.0"
+ ],
+ "summary": "OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, design themes and kits, game assets and creator services. v1 is free and swap only — there is no price, no Billing and no money — and every listing carries a permission passport (reads, writes, spends, contacts) taken from its public manifest. Submitting a listing runs four conformance tests for its version and a draft is only publishable when they pass. A sandbox trial is a manifest/passport dry run (v1 does not execute a listing's code); a passed trial is consumed by the install, which keeps the exact passport snapshot. A revoke is one tap and idempotent. A free listing takes claims and a swap listing takes offers with what the other person gives; accepting closes it as given or swapped. Every public listing is one Search Fabric document queued in the service's outbox, with a tombstone on close or hide.",
+ "works": [
+ "Listings with a unique slug, kind, free/swap mode, SPDX licence and URL, semantic version, manifest and permission passport; no price or money column",
+ "A passport with reads, writes, spends and contacts; spends must be empty (a table check, the form and the API agree)",
+ "Four conformance tests per version (manifest, passport, licence, no_money) stored in exchange_listing_tests; only the owner can publish and only when all pass",
+ "Sandbox trials as a manifest/passport dry run with four recorded checks, a TTL and a daily cap; a passed trial is consumed by install",
+ "Installs with the exact passport snapshot, one active per (listing, person), and one-tap idempotent revoke",
+ "Free claims and swap offers with a required give on swaps; the owner accepts/declines, the sender withdraws; accepting closes the listing and tombstones it",
+ "A fixed prohibited-items and no-money filter on every listing and offer",
+ "The API: public cursor-paged listings, detail, passport, tests, owner drafts, sandbox, install, installs, offers and staff hide",
+ "Search Fabric documents (owner exchange, type listing, /listings/<slug>) in exchange_outbox, with a tombstone when closed or hidden",
+ "Sign-in with OpenVibe.Network (PKCE S256, exchange_at/exchange_rt/exchange_oauth), staff role global_mod+",
+ "Account export and deletion over listings, installs, sandbox trials and offers; a deleted listing is anonymised, closed and tombstoned",
+ "Server-rendered pages with stable ex- ids/classes, cache headers, discovery artifacts, deploy files and the first-proof seed"
+ ],
+ "notYet": [
+ "The Network grant write/revoke: v1 keeps the install as an Exchange record; network.grants.write and network.grants.revoke are proposed and not in contracts v0.229.0",
+ "A real sandbox: v1 is a manifest/passport dry run; executing a listing needs OpenVibe.Run and a run.sandbox.execute capability",
+ "The Search document relay: documents wait in exchange_outbox until Network grants exchange search.document.write",
+ "Paid listings through Billing (v2): no intent, transfer, cashout, entitlement or usage call exists in v1",
+ "Reviews and mutual reviews; the listing page shows no reviews yet",
+ "A user report form and moderation queue beyond the fixed filter and staff hide",
+ "Open data/API listings, Rates and price comparisons (Zone/Services/Tools)",
+ "Skill swaps on a time bank, city free-stuff hubs, a community white-label exchange, and Inventory gifting",
+ "Developer-app/agent routes: an app token is recognised but no v1 route needs one"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "free and swap listings",
+ "permission passport",
+ "conformance tests per version",
+ "sandbox dry run",
+ "install with passport snapshot",
+ "one-tap revoke",
+ "free claims and swap offers",
+ "prohibited-items and no-money filter",
+ "Search Fabric outbox",
+ "account export and deletion",
+ "Network sign-in",
+ "staff hide",
+ "server-rendered pages",
+ "discovery",
+ "deploy files",
+ "first-proof seed"
+ ],
+ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-exchange.service and deploy/nginx/openvibe.exchange.conf",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.exchange — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.exchange.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5150, systemd unit openvibe-exchange) renders every page and serves
+## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses
+## text.
+##
+## No proxy_cache: the app decides cacheability per response (public for the public pages and listings; private,
+## no-store for anything drawn for a signed-in person). Never log request bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=exchange_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=exchange_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=exchange_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.exchange www.openvibe.exchange;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.exchange$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.exchange;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.exchange/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.exchange/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.exchange$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.exchange;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.exchange/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.exchange/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.exchange.access.log;
+ error_log /var/log/nginx/openvibe.exchange.error.log;
+
+ # The API's JSON body cap is 64 KB; leave room for the headers.
+ client_max_body_size 128k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints ───────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ limit_req zone=exchange_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ location /auth/ {
+ limit_req zone=exchange_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=exchange_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ # ── Forms: submit, publish/close, sandbox, install, revoke, offers ──
+ location ~ ^/(submit|listings/[a-z0-9-]+/(list|close|sandbox|install|offers)|installs/[A-Za-z0-9_]+/revoke|offers/[A-Za-z0-9_]+/(accept|decline|withdraw))$ {
+ limit_req zone=exchange_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5150/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5150/internal/events directly. Never proxied from outside (ADR-033).
+ location ~* ^/internal(/|$) { return 404; }
+
+ # ── Pages, docs, robots.txt, sitemap, static assets ─────
+ location / {
+ proxy_pass http://127.0.0.1:5150;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe.Exchange — trade what people make.
+# Network (sign-in, JWKS, account events) should be up first, but Exchange degrades honestly without it: the public
+# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.exchange
+EnvironmentFile=/etc/openvibe/exchange.env
+Environment=NODE_ENV=production
+Environment=PORT=5150
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.exchange/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+# In-flight requests get the 10 s drain.
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# OpenVibe.Exchange'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,218 @@
@@ -0,0 +1,218 @@
+-- phase: expand
+-- OpenVibe.Exchange v1: listings with a permission passport, a sandbox, install/revoke, and free/swap only. There is
+-- no money here: no price, no Billing id, and a passport may not declare a spend. A listing's manifest is public
+-- metadata (name, entry, runtime) and never a credential. An install keeps the exact passport it was installed
+-- with, so a later listing edit cannot widen an old install.
+
+CREATE TABLE exchange_listings (
+ id text COLLATE "C" PRIMARY KEY, -- exl_ + 26 Crockford base32 characters (ULID)
+ slug text COLLATE "C" NOT NULL UNIQUE, -- /listings/<slug>
+ title text NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- agent | mcp_tool | template | theme | game_asset | service
+ mode text COLLATE "C" NOT NULL, -- free | swap (v1: no money)
+ summary text NOT NULL,
+ description text,
+ wants text, -- what a swap would like in return; NULL for free
+ license text COLLATE "C" NOT NULL, -- SPDX identifier
+ license_url text,
+ version text COLLATE "C" NOT NULL, -- semver, the version the tests and passport belong to
+ creator text NOT NULL, -- the maker's public display name, never their subject id
+ owner text COLLATE "C", -- usr_…; NULL after an account deletion anonymises it
+ status text COLLATE "C" NOT NULL DEFAULT 'draft', -- draft | listed | hidden | closed
+ manifest jsonb NOT NULL, -- { name, entry, runtime } only; never a secret
+ passport jsonb NOT NULL, -- { reads, writes, spends, contacts }
+ revision bigint NOT NULL DEFAULT 1, -- +1 on every change; the Search revision
+ listed_at timestamptz,
+ closed_at timestamptz,
+ closed_reason text COLLATE "C", -- given | swapped | withdrawn
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT exchange_listings_id CHECK (id ~ '^exl_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_listings_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
+ CONSTRAINT exchange_listings_title CHECK (length(title) BETWEEN 3 AND 120),
+ CONSTRAINT exchange_listings_kind CHECK (kind IN ('agent', 'mcp_tool', 'template', 'theme', 'game_asset', 'service')),
+ CONSTRAINT exchange_listings_mode CHECK (mode IN ('free', 'swap')),
+ CONSTRAINT exchange_listings_summary CHECK (length(summary) BETWEEN 3 AND 600),
+ CONSTRAINT exchange_listings_description CHECK (description IS NULL OR length(description) <= 4000),
+ CONSTRAINT exchange_listings_wants CHECK (
+ (mode = 'free' AND wants IS NULL) OR (mode = 'swap' AND length(wants) BETWEEN 3 AND 300)
+ ),
+ CONSTRAINT exchange_listings_license CHECK (license ~ '^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$'),
+ CONSTRAINT exchange_listings_license_url CHECK (license_url IS NULL OR license_url ~ '^https://'),
+ CONSTRAINT exchange_listings_version CHECK (version ~ '^[0-9]+\.[0-9]+\.[0-9]+$'),
+ CONSTRAINT exchange_listings_creator CHECK (length(creator) BETWEEN 1 AND 80),
+ CONSTRAINT exchange_listings_owner CHECK (owner IS NULL OR owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_listings_status CHECK (status IN ('draft', 'listed', 'hidden', 'closed')),
+ CONSTRAINT exchange_listings_closed_reason CHECK (closed_reason IS NULL OR closed_reason IN ('given', 'swapped', 'withdrawn')),
+ CONSTRAINT exchange_listings_listed_at CHECK (status <> 'listed' OR listed_at IS NOT NULL),
+ CONSTRAINT exchange_listings_manifest CHECK (
+ jsonb_typeof(manifest) = 'object'
+ AND jsonb_typeof(manifest->'name') = 'string'
+ AND jsonb_typeof(manifest->'entry') = 'string'
+ ),
+ CONSTRAINT exchange_listings_passport CHECK (
+ jsonb_typeof(passport) = 'object'
+ AND jsonb_typeof(passport->'reads') = 'array'
+ AND jsonb_typeof(passport->'writes') = 'array'
+ AND jsonb_typeof(passport->'contacts') = 'array'
+ AND passport->'spends' = '[]'::jsonb
+ ),
+ CONSTRAINT exchange_listings_revision CHECK (revision > 0)
+);
+CREATE INDEX exchange_listings_public ON exchange_listings (status, listed_at DESC, id DESC);
+CREATE INDEX exchange_listings_by_kind ON exchange_listings (status, kind, listed_at DESC);
+CREATE INDEX exchange_listings_by_mode ON exchange_listings (status, mode, listed_at DESC);
+CREATE INDEX exchange_listings_by_owner ON exchange_listings (owner, created_at DESC);
+
+-- The conformance tests run when a listing is submitted, one row per version. A listing is not public until all
+-- of them pass for the version it names. (A Run sandbox executes the listing's own tests in v2; v1 checks the
+-- manifest, passport, licence and the no-money rule.)
+CREATE TABLE exchange_listing_tests (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,
+ version text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL, -- manifest | passport | licence | no_money
+ status text COLLATE "C" NOT NULL, -- passed | failed
+ detail text,
+ ran_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT exchange_listing_tests_status CHECK (status IN ('passed', 'failed')),
+ CONSTRAINT exchange_listing_tests_name CHECK (name IN ('manifest', 'passport', 'licence', 'no_money')),
+ CONSTRAINT exchange_listing_tests_unique UNIQUE (listing_id, version, name)
+);
+CREATE INDEX exchange_listing_tests_by_listing ON exchange_listing_tests (listing_id, version, ran_at DESC);
+
+-- A sandbox trial is a manifest dry run: the passport is checked and the result recorded, but no listing code is
+-- executed (Run has no sandbox today). A passed trial expires; an install needs one for the same version.
+CREATE TABLE exchange_sandbox_trials (
+ id text COLLATE "C" PRIMARY KEY, -- exs_ + ULID
+ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ version text COLLATE "C" NOT NULL,
+ status text COLLATE "C" NOT NULL, -- passed | failed
+ passport jsonb NOT NULL,
+ checks jsonb NOT NULL, -- array of { name, status, detail }
+ created_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ consumed_at timestamptz,
+ CONSTRAINT exchange_sandbox_trials_id CHECK (id ~ '^exs_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_sandbox_trials_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_sandbox_trials_status CHECK (status IN ('passed', 'failed')),
+ CONSTRAINT exchange_sandbox_trials_window CHECK (expires_at > created_at)
+);
+CREATE INDEX exchange_sandbox_trials_by_subject ON exchange_sandbox_trials (subject, created_at DESC);
+CREATE INDEX exchange_sandbox_trials_for_install ON exchange_sandbox_trials (listing_id, subject, version, status, expires_at);
+
+-- One active installation per person and listing. The passport column is the snapshot the install was made with.
+-- v1 has no Network grant to create; this record is the entitlement, and revoke removes it everywhere in Exchange.
+CREATE TABLE exchange_installs (
+ id text COLLATE "C" PRIMARY KEY, -- exi_ + ULID
+ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ version text COLLATE "C" NOT NULL,
+ passport jsonb NOT NULL,
+ status text COLLATE "C" NOT NULL DEFAULT 'active', -- active | revoked
+ installed_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz,
+ revision bigint NOT NULL DEFAULT 1,
+ CONSTRAINT exchange_installs_id CHECK (id ~ '^exi_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_installs_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_installs_status CHECK (status IN ('active', 'revoked')),
+ CONSTRAINT exchange_installs_revoked CHECK ((status = 'revoked') = (revoked_at IS NOT NULL)),
+ CONSTRAINT exchange_installs_revision CHECK (revision > 0)
+);
+CREATE UNIQUE INDEX exchange_installs_active ON exchange_installs (listing_id, subject) WHERE status = 'active';
+CREATE INDEX exchange_installs_by_subject ON exchange_installs (subject, installed_at DESC);
+
+-- A free claim or a swap offer. kind follows the listing's mode; a person has at most one open offer per listing.
+CREATE TABLE exchange_offers (
+ id text COLLATE "C" PRIMARY KEY, -- exo_ + ULID
+ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,
+ from_subject text COLLATE "C" NOT NULL, -- usr_…
+ from_name text NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- claim | swap
+ body text NOT NULL,
+ give text, -- what a swap offers in return
+ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | accepted | declined | withdrawn
+ created_at timestamptz NOT NULL DEFAULT now(),
+ decided_at timestamptz,
+ CONSTRAINT exchange_offers_id CHECK (id ~ '^exo_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_offers_subject CHECK (from_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT exchange_offers_name CHECK (length(from_name) BETWEEN 1 AND 80),
+ CONSTRAINT exchange_offers_kind CHECK (kind IN ('claim', 'swap')),
+ CONSTRAINT exchange_offers_body CHECK (length(body) BETWEEN 3 AND 1000),
+ CONSTRAINT exchange_offers_give CHECK (give IS NULL OR length(give) BETWEEN 3 AND 1000),
+ CONSTRAINT exchange_offers_status CHECK (status IN ('open', 'accepted', 'declined', 'withdrawn'))
+);
+CREATE UNIQUE INDEX exchange_offers_one_open ON exchange_offers (listing_id, from_subject) WHERE status = 'open';
+CREATE INDEX exchange_offers_by_listing ON exchange_offers (listing_id, status, created_at);
+CREATE INDEX exchange_offers_by_sender ON exchange_offers (from_subject, created_at DESC);
+
+-- Every status change a listing makes (draft, listed, hidden, closed), for staff and the owner's audit.
+CREATE TABLE exchange_listing_events (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ listing_id text COLLATE "C" NOT NULL REFERENCES exchange_listings (id) ON DELETE CASCADE,
+ from_status text COLLATE "C",
+ to_status text COLLATE "C" NOT NULL,
+ reason text,
+ changed_by text COLLATE "C", -- usr_…, account-deletion or service:exchange
+ changed_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT exchange_listing_events_from CHECK (from_status IS NULL OR from_status IN ('draft', 'listed', 'hidden', 'closed')),
+ CONSTRAINT exchange_listing_events_to CHECK (to_status IN ('draft', 'listed', 'hidden', 'closed'))
+);
+CREATE INDEX exchange_listing_events_by_listing ON exchange_listing_events (listing_id, changed_at DESC);
+
+-- The Search Fabric outbox (Today's shape). A document for a listed listing is written with this fixed evt_ id in
+-- the same transaction as the change; a draft, hidden or closed listing writes a tombstone. No service grant is
+-- needed to queue it, and the rows wait until OpenVibe.Search's document writer is configured.
+CREATE TABLE exchange_outbox (
+ event_id text COLLATE "C" PRIMARY KEY,
+ topic text COLLATE "C" NOT NULL,
+ payload jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ published_at timestamptz,
+ attempts integer NOT NULL DEFAULT 0,
+ last_error text,
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
+ claimed_at timestamptz,
+ claimed_by text
+);
+CREATE INDEX exchange_outbox_pending ON exchange_outbox (next_attempt_at) WHERE published_at IS NULL;
+
+-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes
+-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
+
+-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
+CREATE TABLE telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -0,0 +1,1407 @@
+{
+ "name": "openvibe-exchange",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-exchange",
+ "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.235.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
+ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.9",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
+ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.235.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
+ "integrity": "sha512-Rfmy2sDZOO+fegEPBweQvCKlamkYwOXGyr1M80VkYHXHl8gg7CnyDL7HrbGwuXG3LUcPqzhAMYeeB//jKMtzaw==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.70.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.31.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-exchange",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Exchange — trade what people make.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "seed:proof": "node scripts/seed-proof.js",
+ "test": "node test/run.js",
+ "test:pg": "EXCHANGE_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.235.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+/* OpenVibe.Exchange: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
+ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
+ first; nothing scrolls sideways. A listing always shows its passport: what it reads, writes and spends. */
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
+.ex { display: block; }
+.ex a { color: var(--room-strong); }
+.ex .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.ex-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.ex-narrow { max-width: 620px; }
+.ex-page-pad { padding-top: clamp(28px, 6vw, 64px); }
+.ex h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.ex-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.ex-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
+.ex-page-h h1, .ex-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
+.ex-lede, .ex-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.ex-fine, .ex-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.ex-section { margin-top: clamp(28px, 4vw, 44px); }
+.ex-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.ex-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
+.ex-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
+.ex-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
+.ex-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
+.ex-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
+.ex-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
+.ex-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
+.ex-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.ex-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.ex-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
+.ex-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
+.ex-empty p { margin: 0; line-height: 1.5; }
+.ex-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+
+
+.ex-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.ex-form { display: grid; gap: 14px; }
+.ex-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
+.ex-field input, .ex-field select, .ex-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
+.ex-field input:focus, .ex-field select:focus, .ex-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
+.ex-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
+.ex-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.ex-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ex-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.ex-eps { display: grid; gap: 10px; margin: 12px 0 0; }
+.ex-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.ex-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
+.ex-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+
+
+.ex-section-head, .ex-more-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.ex-prose { max-width: 720px; line-height: 1.6; color: var(--text-secondary, #96a7c2); }
+.ex-prose-list { margin: 0; padding-left: 20px; display: grid; gap: 6px; line-height: 1.55; color: var(--text-secondary, #96a7c2); }
+.ex-fieldset { margin: 0; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; display: grid; gap: 10px; }
+.ex-field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; }
+.ex-filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
+.ex-filters a { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border, #1f2d47); font-size: .86rem; font-weight: 700; text-decoration: none; color: var(--text-secondary, #96a7c2); }
+.ex-filters a[aria-current] { background: var(--room-fill); color: var(--room-on); border-color: transparent; }
+.ex-inline { margin: 0; display: inline; }
+.ex-ok, .ex-status-active, .ex-verified { color: var(--success-text, #22c55e); font-weight: 800; }
+.ex-status-revoked, .ex-unverified { color: var(--text-muted, #7386a3); font-weight: 800; }
+.ex-status, .ex-kind, .ex-mode, .ex-license, .ex-version { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .75rem; font-weight: 800; background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); color: var(--text-secondary, #96a7c2); }
+.ex-kind, .ex-mode { background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); }
+
+/* The motif: an item looping between two makers, a passport stamped "safe" */
+.ex-motif { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 260; margin: 0 auto; color: var(--room-accent); }
+.ex-motif-arrows { position: absolute; inset: 0; width: 100%; height: 100%; }
+.ex-motif-arrows path { stroke: currentColor; stroke-width: 3; stroke-linecap: round; opacity: .7; }
+html.ov-motion .ex-motif-arrows path { stroke-dasharray: 500; animation: ex-loop 2s cubic-bezier(.2,.7,.2,1) both; }
+@keyframes ex-loop { from { stroke-dashoffset: 500; } }
+.ex-motif-item { position: absolute; padding: 9px 14px; border-radius: 12px; font-size: .86rem; font-weight: 800; background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); color: var(--text-primary, #e6edf7); box-shadow: 0 8px 22px rgba(0, 0, 0, .22); }
+.ex-motif-item-a { left: 4%; top: 18%; } .ex-motif-item-b { right: 4%; top: 18%; }
+.ex-motif-passport { position: absolute; left: 50%; bottom: 8%; translate: -50% 0; display: grid; justify-items: center; padding: 10px 18px; border-radius: 14px; background: var(--room-fill); color: var(--room-on); rotate: -4deg; box-shadow: 0 10px 26px rgba(0, 0, 0, .28); }
+.ex-motif-passport small { font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; opacity: .8; }
+.ex-motif-passport b { font-size: 1.3rem; letter-spacing: .06em; text-transform: uppercase; }
+html.ov-motion .ex-motif-passport { animation: ex-stamp .5s cubic-bezier(.2,.9,.3,1.4) 1.2s both; }
+@keyframes ex-stamp { from { opacity: 0; scale: 1.6; } }
+
+/* Scenes' demos */
+.ex-demo { display: grid; gap: 8px; width: min(100%, 420px); margin: 0 auto; padding: 16px 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.ex-demo-head { font-size: .72rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.ex-demo-checks, .ex-demo-grants { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: .9rem; }
+.ex-demo-grants b { display: inline-block; min-width: 64px; color: var(--text-muted, #7386a3); font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; }
+.ex-demo-row { display: flex; justify-content: space-between; gap: 10px; font-size: .9rem; }
+.ex-demo-stamp { justify-self: start; padding: 3px 10px; border-radius: 8px; font-weight: 900; text-transform: uppercase; letter-spacing: .08em; color: var(--room-on); background: var(--room-fill); rotate: -3deg; }
+.ex-demo-button { justify-self: start; padding: 7px 14px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--error, #ef4444) 50%, transparent); color: var(--error-text, #f87171); font-weight: 800; font-size: .86rem; }
+
+/* Listings */
+.ex-grid { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
+.ex-listing-card { display: grid; }
+.ex-listing-link { display: grid; gap: 8px; height: 100%; padding: 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }
+.ex-listing-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
+.ex-listing-top, .ex-listing-meta, .ex-listing-foot, .ex-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: .82rem; color: var(--text-muted, #7386a3); }
+.ex-listing-title { font-size: 1.08rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
+.ex-listing-summary { margin: 0; color: var(--text-secondary, #96a7c2); font-size: .9rem; line-height: 1.5; }
+.ex-listing-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 2.8rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.08; overflow-wrap: anywhere; }
+.ex-creator, .ex-owner { font-weight: 700; color: var(--text-secondary, #96a7c2); }
+
+/* The passport: what a listing reads, writes and spends */
+.ex-passport { position: relative; display: grid; gap: 12px; padding: 18px; border-radius: 18px; background: color-mix(in srgb, var(--room-accent) 6%, var(--bg-card, #0f1729)); border: 2px solid color-mix(in srgb, var(--room-accent) 40%, var(--border, #1f2d47)); }
+.ex-passport-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
+.ex-passport-row { display: grid; gap: 4px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.ex-passport-row dt, .ex-passport-row b { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ex-passport-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; font: 600 .84rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
+.ex-passport-stamp { position: absolute; right: 16px; top: -12px; padding: 4px 12px; border-radius: 8px; font-weight: 900; letter-spacing: .08em; text-transform: uppercase; color: var(--room-on); background: var(--room-fill); rotate: 4deg; }
+.ex-none { color: var(--text-muted, #7386a3); font-style: italic; }
+.ex-manifest { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .84rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; }
+
+/* Checks and conformance tests */
+.ex-checks, .ex-tests { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.ex-check, .ex-test { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 9px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .88rem; }
+.ex-check-detail, .ex-test-detail { flex-basis: 100%; color: var(--text-muted, #7386a3); font-size: .82rem; }
+.ex-test-name { font-weight: 700; } .ex-test-status { margin-left: auto; font-weight: 800; }
+.ex-check-pass, .ex-test-pass { border-left: 4px solid var(--success, #22c55e); }
+.ex-check-fail, .ex-test-fail { border-left: 4px solid var(--error, #ef4444); }
+
+/* Installs, offers, mine */
+.ex-install-list, .ex-offer-list, .ex-offers-list, .ex-mine-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.ex-install, .ex-offer, .ex-mine-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px 16px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.ex-install-revoked { opacity: .7; }
+.ex-install-main, .ex-offer-body { display: grid; gap: 4px; min-width: 0; }
+.ex-install-title { font-weight: 800; overflow-wrap: anywhere; }
+.ex-install-meta, .ex-offer-head, .ex-offer-when, .ex-offer-from { font-size: .82rem; color: var(--text-muted, #7386a3); }
+.ex-offer-give, .ex-wants { font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.ex-staff { color: var(--warning-text, #f59e0b); }
+
+@media (max-width: 759px) { .sc-room-pic .ex-motif-item, .sc-room-pic .ex-motif-passport { display: none; } }
+@media (prefers-reduced-motion: reduce) { .ex-motif-arrows path, .ex-motif-passport { animation: none !important; } }
+html[data-ov-motion=reduced] .ex-motif-arrows path, html[data-ov-motion=reduced] .ex-motif-passport { animation: none !important; }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+/* OpenVibe.Exchange: the room's own script is designed by the main session; the pages are complete without this file. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * The first proof (plan §5.11): a licensed template listed, tried in the sandbox, installed with its passport's
+ * grants, then revoked cleanly.
+ *
+ * node scripts/seed-proof.js
+ *
+ * It writes one CC0-1.0 Website template as a draft, runs the four conformance tests, lists it, takes it through a
+ * sandbox trial as a second person, installs it with the passport snapshot and revokes that install. Every step
+ * goes through the same store functions the API and the pages use; nothing is inserted behind their back. Re-running
+ * is safe: an existing `starter-site` listing is left as it is.
+ */
+const { ids } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { openStore } = require('../server/db');
+const { createStore, slugify } = require('../server/store');
+const { createSearchIndex } = require('../server/search');
+
+const TITLE = 'Starter site template';
+const SLUG = slugify(TITLE);
+const LICENSE = 'CC0-1.0';
+
+const BODY = Object.freeze({
+ title: TITLE,
+ kind: 'template',
+ mode: 'free',
+ summary: 'A licensed, static starter site: one page, one stylesheet, no tracking and no build step.',
+ description: 'A Website template for a maker\'s first page. Copy it into a Space, change the words, publish. The manifest names a static entry point and the passport asks only to read Search and write Space files; it declares no spend, because v1 has no money.',
+ license: LICENSE,
+ license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
+ version: '1.0.0',
+ manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },
+ passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },
+});
+
+async function runSeed({ config, s, log = console } = {}) {
+ if (!config) config = load();
+ if (!s) s = await openStore(config, { log });
+ const search = createSearchIndex({ config, s });
+ const entries = createStore(s, { search, config });
+
+ const existing = await entries.bySlug(SLUG);
+ if (existing) {
+ log.log(`[seed-proof] ${SLUG} already exists (${existing.id}, ${existing.status}) — left as it is`);
+ return { created: false, listing: entries.wireListing(existing), trial: null, install: null, revoked: null };
+ }
+
+ const maker = ids.newId('user');
+ const installer = ids.newId('user');
+
+ const draft = await entries.createListing({ owner: maker, creator: 'OpenVibe', body: BODY });
+ if (!draft.ok) throw new Error(`listing was refused: ${draft.message}`);
+ const listed = await entries.publish(draft.listing.slug, { owner: maker });
+ if (!listed.ok) throw new Error(`listing was not published: ${listed.message}`);
+
+ const sandbox = await entries.sandbox(listed.listing.slug, { subject: installer });
+ if (!sandbox.ok) throw new Error(`sandbox was refused: ${sandbox.message}`);
+ if (sandbox.trial.status !== 'passed') throw new Error(`sandbox trial did not pass: ${JSON.stringify(sandbox.checks)}`);
+
+ const installed = await entries.install(listed.listing.slug, { subject: installer });
+ if (!installed.ok) throw new Error(`install was refused: ${installed.message}`);
+
+ const revoked = await entries.revoke(installed.install.id, { subject: installer });
+ if (!revoked.ok) throw new Error(`revoke was refused: ${revoked.message}`);
+
+ log.log(`[seed-proof] listed ${listed.listing.id} /listings/${listed.listing.slug} (${LICENSE}, free, version ${listed.listing.version})`);
+ log.log(`[seed-proof] sandbox ${sandbox.trial.id}: ${sandbox.trial.status}; passport reads ${sandbox.trial.passport.reads.join(', ') || 'nothing'}, writes ${sandbox.trial.passport.writes.join(', ') || 'nothing'}, spends nothing`);
+ log.log(`[seed-proof] installed ${installed.install.id} and revoked it (${revoked.install.status})`);
+ return { created: true, listing: listed.listing, trial: sandbox.trial, install: installed.install, revoked: revoked.install };
+}
+
+async function main() {
+ const config = load();
+ const s = await openStore(config, { log: console });
+ try {
+ const out = await runSeed({ config, s });
+ process.exitCode = out && out.listing ? 0 : 1;
+ } finally {
+ await s.close();
+ }
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = { runSeed, SLUG, TITLE, LICENSE, BODY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+
+/**
+ * OpenVibe.Exchange — Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database and a mock Network.
+ *
+ * /, /listings, /listings/:slug, /submit, /sandbox/:id, /installs, /offers, /safety, /developers
+ * the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /internal/events account export/deletion (loopback only)
+ *
+ * There are no jobs in v1: sandbox trials run on request and expire by timestamp. The Search relay is not wired.
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, send } = require('./render/layout');
+const { raw } = require('./render/html');
+
+const accountDataLib = require('./identity/account-data');
+const { createStore } = require('./store');
+const { createSearchIndex } = require('./search');
+const { 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 search = createSearchIndex({ config, s });
+ const entries = createStore(s, { search, config });
+
+ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
+ // client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
+
+ const ctx = { config, s, keys, sso, principal, search, entries, accountData, accountSend, log };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'exchange', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'exchange', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'exchange', 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'"],
+ 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-exchange', name: 'OpenVibe.Exchange', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Who is asking (verified offline; refreshed when expired) ──
+ app.use(sso.middleware());
+
+ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', sso.routes());
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'exchange', service: 'exchange', host: 'openvibe.exchange', name: 'OpenVibe.Exchange', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
+ // nginx answers 404 for /internal/.
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.EXCHANGE_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.EXCHANGE_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => send(res, 404, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
+ body: raw(require('./views').notFound()),
+ }));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Exchange]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's or app'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.Exchange] 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.Exchange] 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.Exchange] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `exchange`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
+ * (?silent=1 adds prompt=none; ?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
+ *
+ * This is Meme's SSO module with the client id and cookie names changed. The person's Network access token lives
+ * in an httpOnly cookie (exchange_at) no script can read; the refresh token (exchange_rt) is httpOnly too. Neither
+ * is ever stored server-side, rendered or logged.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'exchange_at';
+const REFRESH_COOKIE = 'exchange_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'exchange_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function ownOrigins(config) {
+ const out = new Set();
+ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
+ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
+ }
+ return out;
+}
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next, config) {
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null;
+ try { u = new URL(next); } catch { return '/'; }
+ if (!ownOrigins(config).has(u.origin)) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ /**
+ * An Exchange session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
+ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ res.set('Clear-Site-Data', '"cache"');
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Exchange] sign-in exchange failed:', err.status || '', err.error || '');
+ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
+ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
+ }
+ });
+
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET';
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Exchange?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.set('Clear-Site-Data', '"cache"');
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next, config));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+
+/**
+ * OpenVibe.Exchange configuration. Every value comes from the environment (production:
+ * /etc/openvibe/exchange.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);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5150);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.exchange' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'exchange',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.EXCHANGE_DRILL === '1' || env.EXCHANGE_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.EXCHANGE_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.EXCHANGE_LIMITS_HOUR, 3000)),
+ },
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used.
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.EXCHANGE_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:exchange:' },
+
+ // 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.EXCHANGE_AUDIENCE || 'openvibe.exchange',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'exchange',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
+ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
+ events: {
+ secrets: String(env.EXCHANGE_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.EXCHANGE_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.EXCHANGE_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.EXCHANGE_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.EXCHANGE_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // The sandbox: a manifest dry run, not a code execution. A passed trial is good for this many minutes and
+ // is required before an install; a person gets at most this many trials a day.
+ sandbox: {
+ ttlMinutes: Math.min(240, Math.max(5, int(env.EXCHANGE_SANDBOX_TTL_MINUTES, 60))),
+ perDay: Math.min(100, Math.max(1, int(env.EXCHANGE_SANDBOX_PER_DAY, 20))),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/**
+ * OpenVibe.Exchange's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret. A listing's manifest is public metadata, never a credential: an entry
+ * that looks like a secret is refused before it is stored, and the passport is what the listing may read, write,
+ * spend or contact — a public declaration, not a key.
+ */
+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.Exchange] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'exchange', 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: 'exchange-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'exchange', 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,254 @@
@@ -0,0 +1,254 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Exchange's JSON API.
+ *
+ * GET /listings?limit=&cursor=&mode=&kind=&q= public listings, newest first, cursor-paged
+ * GET /listings/:slug one listing with its passport, tests and licence
+ * GET /listings/:slug/passport the exact passport and manifest an installer accepts
+ * GET /listings/:slug/tests the conformance tests for this version
+ * GET /listings/:slug/offers the open free claims / swap offers (owner or staff)
+ * GET /me/listings the caller's listings, all statuses
+ * POST /listings create a draft (signed in, same-origin)
+ * POST /listings/:slug/list publish after the conformance tests passed (owner)
+ * POST /listings/:slug/close close (given / swapped / withdrawn) (owner)
+ * POST /listings/:slug/sandbox run a manifest dry-run trial (signed in)
+ * GET /sandbox/:id one trial of yours
+ * POST /listings/:slug/install install with the passport snapshot (signed in; trial required)
+ * GET /installs the caller's installations
+ * POST /installs/:id/revoke revoke one installation (signed in)
+ * POST /listings/:slug/offers a free claim or a swap offer (signed in)
+ * GET /offers offers the caller sent and received
+ * POST /offers/:id/accept|decline|withdraw decide an offer
+ * POST /listings/:slug/hide staff: hide a listing
+ *
+ * Errors are application/problem+json with a `code`. A person is the exchange_at session cookie or a Network user
+ * token as a Bearer; a session write must come from openvibe.exchange itself (403 exchange.cross_site). There is no
+ * money: a listing is free or a swap, and a passport may not declare a spend.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { isStaff } = require('../identity/staff');
+const { KINDS, MODES } = require('../store');
+
+function createApi(ctx) {
+ const { config, s, principal, entries } = ctx;
+ const r = asyncRouter();
+
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and
+ // hour budget from config, and an app or anonymous caller is left to the address limiter.
+ const buckets = new Map();
+ function windowCount(key, windowMs, limit) {
+ const now = Date.now();
+ let b = buckets.get(key);
+ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }
+ b.n += 1;
+ return b.n <= limit;
+ }
+ r.use((req, res, next) => {
+ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {
+ const who = `user:${req.principal.subject}`;
+ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);
+ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);
+ if (!okMinute || !okHour) return problem(res, 429, 'exchange.rate_limited', 'too many requests from this account; retry shortly');
+ if (buckets.size > 20_000) {
+ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);
+ }
+ }
+ return next();
+ });
+
+ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
+ const invalid = (res, detail) => problem(res, 400, 'exchange.invalid', detail);
+
+ function person(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') { problem(res, 401, 'exchange.sign_in_required', 'Sign in at openvibe.exchange, or send a person\'s Network token as a Bearer.'); return null; }
+ if (p.kind !== 'user') { problem(res, 403, 'exchange.person_required', 'This route is for a person, not an app.'); return null; }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ problem(res, 403, 'exchange.cross_site', 'A signed-in request that changes something must come from openvibe.exchange itself.');
+ return null;
+ }
+ return p;
+ }
+ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
+ const requireStaff = (req, res, next) => {
+ if (!person(req, res)) return undefined;
+ if (!isStaff(req.principal)) return problem(res, 403, 'exchange.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');
+ return next();
+ };
+
+ const creatorOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.displayName) || req.principal.username || 'a maker';
+
+ /** A listing the caller may see: public, or their own, or staff. */
+ async function visible(req, res, slug) {
+ const row = await entries.bySlug(String(slug || ''));
+ if (!row) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }
+ const mine = req.principal.kind === 'user' && row.owner === req.principal.subject;
+ const staff = isStaff(req.principal);
+ if (row.status !== 'listed' && !mine && !staff) { problem(res, 404, 'exchange.not_found', 'No such listing.'); return null; }
+ return { row, mine, staff };
+ }
+
+ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // ── Public reads ────────────────────────────────────────
+ r.get('/listings', async (req, res) => {
+ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
+ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');
+ const mode = typeof req.query.mode === 'string' && req.query.mode ? req.query.mode : null;
+ if (mode && !MODES.includes(mode)) return invalid(res, `mode must be one of ${MODES.join(', ')}`);
+ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;
+ if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);
+ const out = await entries.list({
+ limit, mode, kind,
+ q: typeof req.query.q === 'string' ? req.query.q : null,
+ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
+ });
+ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out');
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ count: out.listings.length, listings: out.listings, next_cursor: out.next_cursor });
+ });
+
+ r.get('/listings/:slug', async (req, res) => {
+ const found = await visible(req, res, req.params.slug);
+ if (!found) return undefined;
+ const detail = await entries.detail(found.row.slug);
+ if (!detail) return problem(res, 404, 'exchange.not_found', 'No such listing.');
+ const out = { ...detail.listing, mine: found.mine, staff: found.staff };
+ if (!found.mine && !found.staff) delete out.owner;
+ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
+ return res.json(out);
+ });
+
+ r.get('/listings/:slug/passport', async (req, res) => {
+ const found = await visible(req, res, req.params.slug);
+ if (!found) return undefined;
+ const out = {
+ slug: found.row.slug,
+ version: found.row.version,
+ mode: found.row.mode,
+ license: found.row.license,
+ creator: found.row.creator,
+ manifest: entries.wireListing(found.row).manifest,
+ passport: entries.wirePassport(found.row.passport),
+ tests: await entries.testsFor(found.row.id, found.row.version),
+ };
+ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
+ return res.json(out);
+ });
+
+ r.get('/listings/:slug/tests', async (req, res) => {
+ const found = await visible(req, res, req.params.slug);
+ if (!found) return undefined;
+ const rows = await entries.testsFor(found.row.id, found.row.version);
+ res.set('Cache-Control', found.row.status === 'listed' ? 'public, max-age=60' : 'private, no-store');
+ return res.json({ version: found.row.version, count: rows.length, verified: rows.length > 0 && rows.every((t) => t.status === 'passed'), tests: rows });
+ });
+
+ r.get('/listings/:slug/offers', requirePerson, async (req, res) => {
+ const found = await visible(req, res, req.params.slug);
+ if (!found) return undefined;
+ if (!found.mine && !found.staff) return problem(res, 403, 'exchange.owner_only', 'Only the listing owner or staff can read its offers.');
+ return res.json({ count: (await entries.offersForListing(found.row.id)).length, offers: await entries.offersForListing(found.row.id) });
+ });
+
+ r.get('/me/listings', requirePerson, async (req, res) => {
+ const listings = await entries.listMine(req.principal.subject);
+ return res.json({ count: listings.length, listings });
+ });
+
+ // ── Listings (a person) ─────────────────────────────────
+ r.post('/listings', requirePerson, async (req, res) => {
+ const out = await entries.createListing({ owner: req.principal.subject, creator: creatorOf(req), body: req.body || {} });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'invalid listing');
+ res.set('Location', `/api/v1/listings/${out.listing.slug}`);
+ return res.status(201).json(out.listing);
+ });
+
+ r.post('/listings/:slug/list', requirePerson, async (req, res) => {
+ const out = await entries.publish(String(req.params.slug || ''), { owner: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot list');
+ return res.json(out.listing);
+ });
+
+ r.post('/listings/:slug/close', requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const out = await entries.close(String(req.params.slug || ''), { owner: req.principal.subject, reason: b.reason });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot close');
+ return res.json(out.listing);
+ });
+
+ r.post('/listings/:slug/hide', requireStaff, async (req, res) => {
+ const out = await entries.hide(String(req.params.slug || ''), { changedBy: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot hide');
+ return res.json(out.listing);
+ });
+
+ // ── Sandbox ─────────────────────────────────────────────
+ r.post('/listings/:slug/sandbox', requirePerson, async (req, res) => {
+ const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot start the sandbox');
+ res.set('Location', `/api/v1/sandbox/${out.trial.id}`);
+ return res.status(201).json({ trial: out.trial, checks: out.checks });
+ });
+
+ r.get('/sandbox/:id', requirePerson, async (req, res) => {
+ const out = await entries.trialById(String(req.params.id || ''), { subject: req.principal.subject });
+ if (!out) return problem(res, 404, 'exchange.trial_not_found', 'No such sandbox trial of yours.');
+ return res.json(out.trial);
+ });
+
+ // ── Installs ────────────────────────────────────────────
+ r.post('/listings/:slug/install', requirePerson, async (req, res) => {
+ const out = await entries.install(String(req.params.slug || ''), { subject: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot install');
+ if (!out.unchanged) res.set('Location', `/api/v1/installs`);
+ return res.status(out.unchanged ? 200 : 201).json(out.install);
+ });
+
+ r.get('/installs', requirePerson, async (req, res) => {
+ const installs = await entries.installsFor(req.principal.subject);
+ return res.json({ count: installs.length, installs });
+ });
+
+ r.post('/installs/:id/revoke', requirePerson, async (req, res) => {
+ const out = await entries.revoke(String(req.params.id || ''), { subject: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot revoke');
+ return res.json(out.install);
+ });
+
+ // ── Free claims and swap offers ─────────────────────────
+ r.post('/listings/:slug/offers', requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const out = await entries.offer(String(req.params.slug || ''), {
+ subject: req.principal.subject, fromName: creatorOf(req), body: b.body, give: b.give,
+ });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || 'cannot send the offer');
+ res.set('Location', `/api/v1/offers`);
+ return res.status(201).json(out.offer);
+ });
+
+ r.get('/offers', requirePerson, async (req, res) => {
+ const out = await entries.offersFor(req.principal.subject);
+ return res.json({ sent: out.sent.length, received: out.received.length, ...out });
+ });
+
+ for (const action of ['accept', 'decline', 'withdraw']) {
+ r.post(`/offers/:id/${action}`, requirePerson, async (req, res) => {
+ const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.principal.subject, action });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'exchange.invalid', out.message || `cannot ${action} the offer`);
+ return res.json({ offer: out.offer, ...(out.closed ? { listing: out.closed } : {}) });
+ });
+ }
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.exchange: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public listings are
+ * for search engines and AI crawlers; sign-in, the API, the internal route and a non-listed listing are not. A
+ * sitemap also lists every public listing page (/listings/<slug>).
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Exchange';
+const DESCRIPTION = 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation. Free and swap only in v1.';
+const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/sandbox/', '/installs', '/offers', '/submit'];
+
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+function siteUpdated() {
+ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
+}
+
+function homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'ShoppingApplication', keywords: 'openvibe exchange, marketplace, free and swap, permission passport, sandbox, install, revoke, templates, themes, game assets' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function publicPages() {
+ return [
+ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Exchange home', text: 'Trade what people make: free and swap listings, each with a permission passport, a sandbox and one-tap revocation.' },
+ { path: '/listings', changefreq: 'daily', priority: 0.9, title: 'All listings', text: 'Every public free and swap listing: agents, MCP tools, templates, themes, game assets and creator services.' },
+ { path: '/safety', changefreq: 'monthly', priority: 0.6, title: 'Free and swap safety rules', text: 'No money, no prohibited items, read the passport, meet safely and revoke any time.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Exchange API', text: 'The JSON API, the passport, the sandbox, installs and the Search document.' },
+ ];
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config, s } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+ const pages = publicPages();
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# openvibe.exchange: public listings are for search and AI crawlers; sign-in, the API and private routes are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'OpenVibe.Exchange is the OpenVibe marketplace for what people make: agents, MCP tools, templates (Website, Report, Space), design themes and kits, game assets and creator services. Every public listing shows a permission passport (exactly what it can read, write, spend or contact), its licence, the conformance tests for that version, and a sandbox. A passed sandbox trial is required before an install; a revoke removes the installation in one tap. v1 is free and swap only: there is no money and a passport may not declare a spend.',
+ details: 'GET /api/v1/listings lists public listings; GET /api/v1/listings/:slug adds the passport, manifest, licence, tests and counters; GET /api/v1/listings/:slug/passport is the exact passport an installer accepts. The sandbox is a manifest dry run in v1: Exchange does not execute a listing\'s code. Every public listing is one search.index-document@1 (owner exchange, type listing) queued in exchange_outbox.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Exchange', url: abs('/'), note: 'free and swap listings' },
+ { title: 'All listings', url: abs('/listings') },
+ { title: 'Safety rules', url: abs('/safety') },
+ { title: 'The API', url: abs('/developers') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Public listings (JSON)', url: abs('/api/v1/listings') },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'Full text for language models', url: abs('/llms-full.txt') },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ { title: 'Elsewhere', links: [
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, staff roles and the grants a passport names' },
+ { title: 'OpenVibe.Run', url: 'https://openvibe.run', note: 'the sandbox that will execute a listing\'s tests' },
+ { title: 'OpenVibe.Search', url: 'https://openvibe.search', note: 'where public listings are indexed' },
+ { title: 'OpenVibe.Rent', url: 'https://openvibe.rent', note: 'the listings engine and local safety rules Exchange borrows' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', async (_req, res) => {
+ const rows = await s.db.many("SELECT slug, title, summary, kind, mode, license FROM exchange_listings WHERE status = 'listed' ORDER BY listed_at DESC LIMIT 2000");
+ const listings = rows.map((e) => ({ url: `/listings/${e.slug}`, title: e.title, text: [e.kind, e.mode, e.license, e.summary].filter(Boolean).join(' · ') }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Exchange: the home, all listings, the safety rules, the API and every public listing.',
+ base: site,
+ maxBytes: 512 * 1024,
+ sections: [
+ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
+ { title: 'Listings', pages: listings },
+ ],
+ }));
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ const lastmod = siteUpdated();
+ const rows = await s.db.many("SELECT slug, updated_at FROM exchange_listings WHERE status = 'listed' ORDER BY slug LIMIT 5000");
+ const urls = [
+ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
+ ...rows.map((e) => ({ loc: abs(`/listings/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),
+ ];
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,277 @@
@@ -0,0 +1,277 @@
+'use strict';
+
+/**
+ * Exchange's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real
+ * link. Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
+ * `private, no-store` and a page that needs an account is not cached.
+ *
+ * / the hero, the three scenes, fresh listings
+ * /listings browse and filter (free/swap, kind, q)
+ * /listings/:slug passport, tests, sandbox, install/revoke, free claim or swap offer
+ * /submit create a listing (draft; POST /submit)
+ * /sandbox/:id one sandbox trial and its checks
+ * /installs the caller's installations and one-tap revoke
+ * /offers free claims and swap offers, sent and received
+ * /safety the free and swap rules
+ * /developers the API
+ * 404 the not-found page
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { sameOrigin } = require('./principal');
+const { html, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { isStaff } = require('../identity/staff');
+const views = require('../views');
+
+const PUBLIC_CACHE = 'public, max-age=60';
+
+function createPageRoutes(ctx) {
+ const { config, entries } = ctx;
+ const r = asyncRouter();
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const staffViewer = (req) => isStaff(req.viewer);
+ const creatorOf = (req) => (req.viewer && (req.viewer.displayName || req.viewer.username)) || 'a maker';
+ const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>That came from somewhere else</h1><p class="ex-muted">Use the form on <a href="/">OpenVibe.Exchange</a>.</p></div></div>`) }, 403);
+ const problemOf = (req) => (typeof req.query.problem === 'string' && req.query.problem ? req.query.problem.slice(0, 300) : null);
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ const [list, counts] = await Promise.all([entries.list({ limit: 8 }), entries.counts()]);
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: null,
+ description: 'OpenVibe.Exchange: trade what people make. Free and swap listings for agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport, a sandbox and one-tap revocation.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ listings: list.listings, counts })),
+ });
+ });
+
+ // ── Browse ───────────────────────────────────────────────
+ r.get('/listings', async (req, res) => {
+ const mode = typeof req.query.mode === 'string' && ['free', 'swap'].includes(req.query.mode) ? req.query.mode : null;
+ const kind = typeof req.query.kind === 'string' && entries.KINDS.includes(req.query.kind) ? req.query.kind : null;
+ const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 120) : '';
+ const out = await entries.list({
+ limit: 24, mode, kind, q,
+ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
+ });
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'All listings',
+ description: 'Every public free and swap listing on OpenVibe.Exchange: agents, MCP tools, templates, themes, game assets and creator services, each with a permission passport.',
+ body: raw(views.listingsPage({ listings: out.listings || [], nextCursor: out.next_cursor || null, filters: { q, mode: mode || '', kind: kind || '' } })),
+ });
+ });
+
+ // ── One listing ──────────────────────────────────────────
+ async function renderListing(req, res, { slug, status = 200, problem = null } = {}) {
+ const detail = await entries.detail(String(slug || ''));
+ if (!detail) return notFound(req, res);
+ const row = detail.row;
+ const mine = Boolean(signedIn(req) && row.owner === req.viewer.subject);
+ const staff = staffViewer(req);
+ if (row.status !== 'listed' && !mine && !staff) return notFound(req, res);
+ const listing = detail.listing;
+ const subject = signedIn(req) ? req.viewer.subject : null;
+ const [activeInstall, trialPassed, offers] = await Promise.all([
+ subject ? entries.activeInstall(row.id, subject) : null,
+ subject ? entries.hasPassedTrial(row.id, subject, row.version) : false,
+ (mine || staff) ? entries.offersForListing(row.id) : [],
+ ]);
+ const notice = req.query.submitted === '1' ? 'Draft saved. Run the conformance tests below; when they pass you can list it publicly.'
+ : req.query.offered === '1' ? 'Your offer is sent. The maker decides.'
+ : req.query.closed === '1' ? 'The listing is closed.' : null;
+ const jsonLd = seo.jsonLd.webPage({
+ name: listing.title,
+ url: `${base()}/listings/${listing.slug}`,
+ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
+ siteUrl: base(),
+ });
+ return page(req, res, {
+ index: row.status === 'listed',
+ cache: row.status === 'listed' ? PUBLIC_CACHE : null,
+ title: listing.title,
+ description: (listing.summary || `${listing.title} on OpenVibe.Exchange`).slice(0, 160),
+ jsonLd: [jsonLd],
+ body: raw(views.listingPage({
+ listing, tests: detail.listing.tests && detail.listing.tests.rows ? detail.listing.tests.rows : [],
+ mine, staff, signedIn: Boolean(subject), activeInstall, trialPassed,
+ offers, received: mine,
+ problem: problem || problemOf(req), notice: req.query.revoked === '1' ? 'Revoked.' : notice,
+ })),
+ }, status);
+ }
+
+ r.get('/listings/:slug', (req, res) => renderListing(req, res, { slug: req.params.slug }));
+
+ // ── Submit (create a draft) ──────────────────────────────
+ function formValues(b = {}) {
+ return {
+ title: b.title || '', kind: b.kind || 'template', mode: b.mode || 'free', summary: b.summary || '',
+ description: b.description || '', wants: b.wants || '', license: b.license || 'CC0-1.0',
+ license_url: b.license_url || '', version: b.version || '1.0.0',
+ manifest_name: b.manifest_name || '', manifest_entry: b.manifest_entry || '', manifest_runtime: b.manifest_runtime || '',
+ passport_reads: b.passport_reads || '', passport_writes: b.passport_writes || '', passport_contacts: b.passport_contacts || '',
+ };
+ }
+ const splitList = (v) => String(v == null ? '' : v).split(/[\n,]+/).map((x) => x.trim()).filter(Boolean);
+
+ r.get('/submit', async (req, res) => {
+ const mine = signedIn(req) ? await entries.listMine(req.viewer.subject) : [];
+ return page(req, res, {
+ index: false,
+ title: 'List something',
+ description: 'List an agent, MCP tool, template, theme, game asset or creator service on OpenVibe.Exchange. Free or swap only in v1.',
+ body: raw(views.submitForm({ values: {}, mine })),
+ });
+ });
+
+ r.post('/submit', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'List something', body: raw(views.signIn({ title: 'List something', text: 'A listing belongs to your OpenVibe account, so you can manage it later.', next: '/submit' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = req.body || {};
+ const body = {
+ title: b.title, kind: b.kind, mode: b.mode, summary: b.summary, description: b.description, wants: b.wants,
+ license: b.license, license_url: b.license_url, version: b.version,
+ manifest: { name: b.manifest_name, entry: b.manifest_entry, runtime: b.manifest_runtime },
+ passport: {
+ reads: splitList(b.passport_reads), writes: splitList(b.passport_writes), spends: [], contacts: splitList(b.passport_contacts),
+ },
+ };
+ const out = await entries.createListing({ owner: req.viewer.subject, creator: creatorOf(req), body });
+ if (!out.ok) {
+ const mine = await entries.listMine(req.viewer.subject);
+ return page(req, res, {
+ title: 'List something', body: raw(views.submitForm({ values: formValues(b), mine, problem: out.message || 'That listing could not be saved.' })),
+ }, out.status || 400);
+ }
+ return res.redirect(303, `/listings/${encodeURIComponent(out.listing.slug)}?submitted=1`);
+ });
+
+ // ── Owner actions ────────────────────────────────────────
+ r.post('/listings/:slug/list', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.publish(String(req.params.slug || ''), { owner: req.viewer.subject });
+ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?listed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot list it')}`}`);
+ });
+
+ r.post('/listings/:slug/close', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.close(String(req.params.slug || ''), { owner: req.viewer.subject, reason: req.body && req.body.reason });
+ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}${out.ok ? '?closed=1' : `?problem=${encodeURIComponent(out.message || 'Cannot close it')}`}`);
+ });
+
+ // ── Sandbox ──────────────────────────────────────────────
+ r.post('/listings/:slug/sandbox', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.sandbox(String(req.params.slug || ''), { subject: req.viewer.subject });
+ if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot start the sandbox')}`);
+ return res.redirect(303, `/sandbox/${encodeURIComponent(out.trial.id)}`);
+ });
+
+ r.get('/sandbox/:id', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sandbox trial', body: raw(views.signIn({ title: 'Sandbox trial', text: 'A trial belongs to your account; sign in to see it.', next: `/sandbox/${req.params.id}` })) }, 401);
+ const out = await entries.trialById(String(req.params.id || ''), { subject: req.viewer.subject });
+ if (!out) return notFound(req, res);
+ return page(req, res, {
+ index: false,
+ title: 'Sandbox trial',
+ description: 'A manifest and passport dry run on OpenVibe.Exchange.',
+ body: raw(views.sandboxPage({ trial: out.trial, listing: out.listing })),
+ });
+ });
+
+ // ── Installs ─────────────────────────────────────────────
+ r.post('/listings/:slug/install', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.install(String(req.params.slug || ''), { subject: req.viewer.subject });
+ if (!out.ok) return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?problem=${encodeURIComponent(out.message || 'Cannot install')}`);
+ return res.redirect(303, `/installs?installed=1`);
+ });
+
+ r.get('/installs', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Your installations', body: raw(views.signIn({ title: 'Your installations', text: 'An installation belongs to your account; sign in to manage it.', next: '/installs' })) }, 401);
+ const installs = await entries.installsFor(req.viewer.subject);
+ return page(req, res, {
+ index: false,
+ title: 'Your installations',
+ description: 'Everything you have installed on OpenVibe.Exchange, with its passport and a one-tap revoke.',
+ body: raw(views.installsPage({ installs })),
+ });
+ });
+
+ r.post('/installs/:id/revoke', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Finstalls');
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.revoke(String(req.params.id || ''), { subject: req.viewer.subject });
+ return res.redirect(303, `/installs?${out.ok ? 'revoked=1' : `problem=${encodeURIComponent(out.message || 'Cannot revoke it')}`}`);
+ });
+
+ // ── Free claims and swap offers ──────────────────────────
+ r.get('/offers', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Your offers', body: raw(views.signIn({ title: 'Your offers', text: 'Offers belong to your account; sign in to see them.', next: '/offers' })) }, 401);
+ const out = await entries.offersFor(req.viewer.subject);
+ return page(req, res, {
+ index: false,
+ title: 'Your offers',
+ description: 'The free claims and swap offers you sent and received on OpenVibe.Exchange.',
+ body: raw(views.offersPage(out)),
+ });
+ });
+
+ r.post('/listings/:slug/offers', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/listings/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.offer(String(req.params.slug || ''), {
+ subject: req.viewer.subject, fromName: creatorOf(req),
+ body: req.body && req.body.body, give: req.body && req.body.give,
+ });
+ return res.redirect(303, `/listings/${encodeURIComponent(String(req.params.slug))}?${out.ok ? 'offered=1' : `problem=${encodeURIComponent(out.message || 'Cannot send the offer')}`}`);
+ });
+
+ for (const action of ['accept', 'decline', 'withdraw']) {
+ r.post(`/offers/:id/${action}`, async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Foffers');
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await entries.decideOffer(String(req.params.id || ''), { subject: req.viewer.subject, action });
+ return res.redirect(303, `/offers?${out.ok ? `${action}ed=1` : `problem=${encodeURIComponent(out.message || `Cannot ${action} the offer`)}`}`);
+ });
+ }
+
+ // ── Static pages ─────────────────────────────────────────
+ r.get('/safety', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Free and swap safety',
+ description: 'OpenVibe.Exchange v1 has no money: free and swap only. Read the passport, meet safely, and revoke any time.',
+ body: raw(views.safetyPage()),
+ }));
+
+ r.get('/developers', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'The OpenVibe.Exchange API',
+ description: 'The JSON API, the permission passport, the sandbox, installs and the Search document.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Exchange API', url: `${base()}/developers`, description: 'The JSON API, the passport, the sandbox, installs and the Search document.', siteUrl: base() })],
+ body: raw(views.developersPage()),
+ }));
+
+ // ── Discovery ───────────────────────────────────────────
+ r.use(createDiscoveryRoutes(ctx));
+
+ function notFound(req, res) {
+ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);
+ }
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'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 (exchange_at).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience openvibe.exchange. No Exchange v1 route needs one: the
+ * sandbox and installs are acts of a person. The shape is kept so a developer-app listing route can be added
+ * in one place once Network grants the capabilities named in README.
+ * { 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 install, revoke or offer.
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// Exchange's v1 routes are a person acting for themself, a public read, and staff hiding a listing. No service
+// capability is required by this service. The grants it will ask Network for are external: see README.
+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.Exchange's capabilities`);
+ return function guard(req, res, next) {
+ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.exchange, 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, 'exchange.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.exchange itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'exchange.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
+ };
+ }
+
+ return { middleware, resolve, requireCapability: require_ };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
+ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Account export and deletion → Exchange (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
+ * listings, their installations, their sandbox trials and the offers they made. A listing is anonymised, then
+ * closed (or deleted if it was still a draft) in extraErase, and its Search document is tombstoned in the same
+ * deletion transaction; nobody else's offers or installs are touched.
+ *
+ * The database stores bare usr_ ids in the subject columns. A listing's public creator name is the maker's own
+ * display name; the passport, manifest, licence and tests are public metadata, never a credential.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+const { ids } = require('openvibe-contracts');
+
+const LISTING_COLUMNS = [
+ 'id', 'slug', 'title', 'kind', 'mode', 'summary', 'description', 'wants', 'license', 'license_url', 'version',
+ 'creator', 'status', 'closed_reason', 'revision', 'listed_at', 'closed_at', 'created_at', 'updated_at',
+];
+
+const TABLES = [
+ { table: 'exchange_listings', subject: 'owner', value: (usr) => usr, file: 'listings.json', columns: LISTING_COLUMNS, erase: { anonymize: { creator: 'a deleted maker' } } },
+ { table: 'exchange_installs', subject: 'subject', value: (usr) => usr, file: 'installs.json', erase: 'delete' },
+ { table: 'exchange_sandbox_trials', subject: 'subject', value: (usr) => usr, file: 'sandbox-trials.json', erase: 'delete' },
+ { table: 'exchange_offers', subject: 'from_subject', value: (usr) => usr, file: 'offers.json', erase: 'delete' },
+];
+
+function create({ db, log = console, now = () => Date.now() } = {}) {
+ async function extraErase(t, subjects, { erased, retained, add }) {
+ // The table map anonymised every listing whose owner is one of the subjects; close the public ones and
+ // tombstone their Search documents, delete the drafts outright.
+ const rows = await t.many('SELECT id, slug, status, mode, revision FROM exchange_listings WHERE owner IS NULL');
+ let closed = 0;
+ let deleted = 0;
+ for (const row of rows) {
+ if (row.status === 'draft') {
+ await t.exec('DELETE FROM exchange_listings WHERE id = $1', [row.id]);
+ deleted++;
+ continue;
+ }
+ // Everything a person wrote is replaced, not just the owner link. A swap still needs a wants value
+ // (the table's mode rule), so it gets a neutral one rather than the maker's words.
+ const revision = Math.max(1, Number(row.revision) || 1) + (row.status === 'closed' ? 0 : 1);
+ await t.exec(
+ `UPDATE exchange_listings
+ SET title = 'A deleted listing', summary = 'Removed with its maker''s account.', description = NULL,
+ wants = CASE WHEN mode = 'swap' THEN 'A deleted swap' ELSE NULL END,
+ status = 'closed', closed_reason = COALESCE(closed_reason, 'withdrawn'), closed_at = COALESCE(closed_at, now()),
+ revision = $2, updated_at = now()
+ WHERE id = $1`,
+ [row.id, revision],
+ );
+ if (row.status === 'closed') continue;
+ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);
+ await t.exec("INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, 'closed', 'account deletion', 'account-deletion')", [row.id, row.status]);
+ const eventId = ids.newId('event', now());
+ await t.exec(
+ `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ($1, 'exchange.index_document.deleted', $2::jsonb, $3::timestamptz, $3::timestamptz)`,
+ [eventId, JSON.stringify({ owner: 'exchange', type: 'listing', id: row.id, revision, deleted: true }), new Date(now()).toISOString()],
+ );
+ closed++;
+ }
+ if (closed) add(retained, 'exchange_listings_closed', closed);
+ if (deleted) add(erased, 'exchange_listings_drafts', deleted);
+ }
+
+ return createAccountData({
+ db,
+ service: 'exchange',
+ tables: TABLES,
+ extraErase,
+ note: 'Listings are anonymised and closed (or deleted when still drafts); installations, sandbox trials and offers are erased.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * Who is staff at OpenVibe.Exchange: the Network role claim on the person's session (server/auth/sso.js puts
+ * `role` on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's
+ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.
+ *
+ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.
+ */
+const { staff } = require('openvibe-contracts');
+
+const STAFF_MIN = 'global_mod';
+const STAFF_ROLES = ['global_mod', 'admin', 'owner'];
+
+/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */
+function isStaff(who) {
+ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;
+ if (typeof who.role !== 'string' || !who.role) return false;
+ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }
+}
+
+module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * OpenVibe.Exchange — process entry. `node server/index.js`
+ * Listens on PORT (5150) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then 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.Exchange', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => 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.Exchange] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded
+ // only because the first JWKS fetch had not happened.
+ ctx.keys.ensure().catch(() => {});
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
+ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
+ ctx.telemetryTick.unref();
+ ctx.keys.client.start();
+
+ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
+ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.
+ const subs = config.drill ? null : startSubscriptions({
+ eventsUrl: config.events.url,
+ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
+ secret: config.events.secrets[0] || '',
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ log: ctx.log || console,
+ });
+ const extra = [() => { if (subs) subs.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Exchange] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
+ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has
+ * signed in to yet is not degraded only because the first fetch had not happened.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'exchange',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * This is Meme's layout with Exchange's name, navigation, room accent and assets.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+const { render, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Exchange';
+const TAGLINE = 'Trade what people make.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Listings', href: '/listings' },
+ { label: 'Installations', href: '/installs' },
+ { label: 'Sell or swap', href: '/submit' },
+ { label: 'Developers', href: '/developers' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+/**
+ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles
+ */
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const loginNext = encodeURIComponent(pathNow);
+ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const nav = {
+ service: 'exchange',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ const footer = { service: 'exchange', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${esc(c.label)}</a>` : esc(c.label)}`).join('')}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'exchange' })}
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
+<link rel="stylesheet" href="${ovServe.url('room.css')}">
+<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
+<link rel="stylesheet" href="${asset('css/exchange.css')}">
+<script src="${ovServe.url('motion.js')}" defer></script>
+<script src="${asset('js/exchange.js')}" defer></script>
+${render(scripts)}
+<meta name="ov-boost" content="exchange@${esc(RELEASE)}">
+<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
+<main id="main" class="ex ov-room" style="${rooms.style('exchange')}">
+${crumbs}
+${render(o.body)}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * Free and swap safety rules, shared by the form and the API so a thing cannot be listed through one and refused
+ * by the other.
+ *
+ * v1 has no money anywhere: a listing is free or a swap, the passport's `spends` is empty, and a title, body or
+ * offer that asks for payment is refused. A short prohibited-items list covers what a marketplace must never
+ * carry: weapons, illegal drugs, malware and stolen credentials, sexual services, forged documents, live animals.
+ * This is a fixed list, not a classifier: it catches the plain cases and staff can hide anything else.
+ *
+ * `SAFETY` is the note every listing page carries word for word (the plan's "Rent's listing engine and safety
+ * rules"): see the person first where the swap is physical, never send money, keep the passport visible.
+ */
+
+const SAFETY = 'OpenVibe.Exchange v1 has no payments: a listing is free or a swap, and nobody should send money. Meet in a public place for a local swap, keep the conversation on the listing, and check the permission passport before you install anything.';
+
+const PROHIBITED = Object.freeze([
+ { code: 'weapons', label: 'weapons or ammunition', re: /\b(?:firearms?|guns?|shotguns?|rifles?|pistols?|ammunition|explosives?|silencers?|untraceable weapons?)\b/i },
+ { code: 'drugs', label: 'illegal drugs', re: /\b(?:cocaine|heroin|fentanyl|methamphetamine|mdma|ecstasy|illegal drugs?)\b/i },
+ { code: 'malware', label: 'malware or stolen access', re: /\b(?:malware|ransomware|keyloggers?|spyware|botnets?|credential dumps?|stolen (?:accounts?|credentials?|data|access))\b/i },
+ { code: 'sexual', label: 'sexual services or content', re: /\b(?:csam|child sexual|sexual services?|escort services?|pornography)\b/i },
+ { code: 'forged', label: 'forged or counterfeit documents', re: /\b(?:fake ids?|forged (?:ids?|passports?|documents?)|counterfeit (?:money|currency|documents?))\b/i },
+ { code: 'animals', label: 'live animals', re: /\b(?:live animals?|pupp(?:y|ies)|kittens?)\b/i },
+ { code: 'money', label: 'money or a payment', re: /\b(?:send cash|pay in cash|cash only|wire transfer|bank transfer|western union|moneygram|venmo|paypal|zelle|gift cards?|bitcoin|cryptocurrency|crypto payment|usd|eur|gbp)\b/i },
+]);
+
+const MONEY_AMOUNT = /(?:[£$€¥]\s?\d|\b\d+(?:[.,]\d{1,2})?\s?(?:usd|eur|gbp|vibes?|dollars?|euros?|pounds?)\b)/i;
+
+/** Plain text as it will be stored: CRLF normalised, no control characters (keeping tab and newline), trimmed. */
+function cleanText(value, max) {
+ const s = String(value == null ? '' : value)
+ .replace(/\r\n?/g, '\n')
+ // eslint-disable-next-line no-control-regex
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '')
+ .trim();
+ return max != null ? s.slice(0, max) : s;
+}
+
+/** The first prohibited rule a set of public texts trips, or null. */
+function findProhibited(...texts) {
+ const haystack = texts.filter(Boolean).join('\n');
+ if (!haystack) return null;
+ for (const rule of PROHIBITED) if (rule.re.test(haystack)) return rule;
+ if (MONEY_AMOUNT.test(haystack)) return PROHIBITED.find((r) => r.code === 'money');
+ return null;
+}
+
+/** A refusal shape the store returns and the API/page layers turn into a problem or a form message. */
+function refusals(...texts) {
+ const hit = findProhibited(...texts);
+ if (!hit) return null;
+ return { status: 400, code: 'exchange.prohibited', message: `A listing or offer cannot carry ${hit.label} on OpenVibe.Exchange.`, field: 'text' };
+}
+
+module.exports = { SAFETY, PROHIBITED, findProhibited, refusals, cleanText, MONEY_AMOUNT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * OpenVibe.Exchange's Search Fabric feed. Every public listing is one `search.index-document@1` (owner `exchange`,
+ * type `listing`, page `/listings/<slug>`), written to `exchange_outbox` in the same transaction as the listing
+ * change; a listing that is not `listed` (draft, hidden, closed) writes a tombstone, which wins over any older
+ * revision. The document carries the maker's copy, the passport's declared permissions and the licence, so Search
+ * can filter free/swap and kinds without reading the listing page.
+ *
+ * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer is
+ * configured for `exchange` (README, "Grants it will need"). The relay is deliberately not part of v1.
+ */
+const { ids } = require('openvibe-contracts');
+
+const OWNER = 'exchange';
+const TYPE = 'listing';
+const EVENT_UPSERTED = 'exchange.index_document.upserted';
+const EVENT_DELETED = 'exchange.index_document.deleted';
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const pagePath = (listing) => `/listings/${listing.slug}`;
+
+function documentOf(listing, { baseUrl = 'https://openvibe.exchange' } = {}) {
+ const revision = Math.max(1, Number(listing && listing.revision) || 1);
+ if (!listing || listing.status !== 'listed') return { owner: OWNER, type: TYPE, id: listing.id, revision, deleted: true };
+ const p = listing.passport || {};
+ const reads = Array.isArray(p.reads) ? p.reads : [];
+ const writes = Array.isArray(p.writes) ? p.writes : [];
+ const contacts = Array.isArray(p.contacts) ? p.contacts : [];
+ const body = [
+ listing.title,
+ listing.summary || '',
+ listing.description || '',
+ listing.wants ? `Wanted in return: ${listing.wants}` : '',
+ reads.length ? `Reads: ${reads.join(', ')}` : '',
+ writes.length ? `Writes: ${writes.join(', ')}` : '',
+ contacts.length ? `Contacts: ${contacts.join(', ')}` : '',
+ `Licence: ${listing.license} · version ${listing.version} · ${listing.mode}`,
+ ].filter(Boolean).join('\n').slice(0, 48_000);
+ return {
+ owner: OWNER,
+ type: TYPE,
+ id: listing.id,
+ revision,
+ deleted: false,
+ visibility: 'public',
+ canonical_url: `${String(baseUrl).replace(/\/+$/, '')}${pagePath(listing)}`,
+ title: listing.title,
+ ...(listing.summary ? { summary: listing.summary } : {}),
+ body,
+ facets: {
+ kind: listing.kind,
+ mode: listing.mode,
+ license: listing.license,
+ creator: listing.creator,
+ can_read: reads,
+ can_write: writes,
+ can_contact: contacts,
+ },
+ authorship: 'human',
+ publication_state: 'published',
+ published_at: iso(listing.listed_at || listing.created_at),
+ updated_at: iso(listing.updated_at),
+ indexability: { decision: 'index' },
+ };
+}
+
+function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
+ const nowIso = () => new Date(s.now()).toISOString();
+
+ async function enqueue(topic, payload, t = null) {
+ const db = t || s.db;
+ const eventId = ids.newId('event', s.now());
+ await db.exec(
+ `INSERT INTO exchange_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
+ [eventId, topic, JSON.stringify(payload), nowIso()],
+ );
+ return eventId;
+ }
+
+ /** Queue the current document on the caller's transaction (upsert when listed, tombstone otherwise). */
+ async function sync(t, listing) {
+ const doc = documentOf(listing, { baseUrl });
+ return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);
+ }
+
+ async function remove(t, id, revision) {
+ return await enqueue(EVENT_DELETED, { owner: OWNER, type: TYPE, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);
+ }
+
+ async function pending() {
+ return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE published_at IS NULL'));
+ }
+
+ return { sync, remove, enqueue, documentOf, pending, baseUrl: String(baseUrl).replace(/\/+$/, '') };
+}
+
+module.exports = { createSearchIndex, documentOf, pagePath, OWNER, TYPE, EVENT_UPSERTED, EVENT_DELETED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,640 @@
@@ -0,0 +1,640 @@
+'use strict';
+
+/**
+ * Exchange's data: listings, their permission passports and conformance tests, sandbox trials, installations and
+ * free/swap offers.
+ *
+ * A listing is born `draft` and only its owner can list it, after every conformance test for the current version
+ * passed. A write that changes a public listing writes its Search document (or tombstone) in the same transaction
+ * (server/search.js). An installation is private: it never becomes a Search document. A sandbox trial is a
+ * manifest dry run, not a code execution — Exchange v1 never runs a listing's code (Run has no sandbox today).
+ *
+ * There is no money in v1: a listing's mode is `free` or `swap`, and its passport's `spends` must be empty.
+ * The passport is the listing's public declaration of what it may read, write, spend and contact; installs keep
+ * the exact passport they were installed with, so a later edit to the listing cannot widen an old install.
+ */
+const { cleanText, refusals, SAFETY } = require('./safety');
+
+const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const KINDS = Object.freeze(['agent', 'mcp_tool', 'template', 'theme', 'game_asset', 'service']);
+const MODES = Object.freeze(['free', 'swap']);
+const STATUSES = Object.freeze(['draft', 'listed', 'hidden', 'closed']);
+const CLOSED_REASONS = Object.freeze(['given', 'swapped', 'withdrawn']);
+const OFFER_KINDS = Object.freeze(['claim', 'swap']);
+const OFFER_STATUSES = Object.freeze(['open', 'accepted', 'declined', 'withdrawn']);
+const VERSION_RE = /^\d+\.\d+\.\d+$/;
+const LICENSE_RE = /^[A-Za-z0-9][A-Za-z0-9.+-]{1,63}$/;
+const SCOPE_RE = /^[a-z][a-z0-9_.:-]{1,63}$/;
+const CONTACT_RE = /^https:\/\/[^\s"'<>]{1,200}$/;
+const SECRET_VALUE_RE = /(?:secret|password|passwd|token|api[_-]?key|private[_-]?key|bearer)\s*[:=]|-----BEGIN [A-Z ]*PRIVATE KEY-----/i;
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
+
+function json(v, fallback) {
+ if (v == null) return fallback;
+ if (typeof v === 'object') return v;
+ try { return JSON.parse(v); } catch { return fallback; }
+}
+
+function stringArray(v) {
+ return Array.isArray(v) ? v.map(String) : [];
+}
+
+function wirePassport(v) {
+ const p = json(v, {});
+ return {
+ reads: stringArray(p.reads),
+ writes: stringArray(p.writes),
+ spends: stringArray(p.spends),
+ contacts: stringArray(p.contacts),
+ };
+}
+
+function wireManifest(v) {
+ const m = json(v, {});
+ return { name: m.name || '', entry: m.entry || '', runtime: m.runtime || null };
+}
+
+function wireTest(row) {
+ if (!row) return null;
+ return { id: Number(row.id), name: row.name, status: row.status, detail: row.detail == null ? null : row.detail, ran_at: iso(row.ran_at) };
+}
+
+function wireListing(row, { tests = null, includeOwner = false, summaryTests = false } = {}) {
+ if (!row) return null;
+ const out = {
+ id: row.id,
+ slug: row.slug,
+ title: row.title,
+ kind: row.kind,
+ mode: row.mode,
+ summary: row.summary == null ? null : row.summary,
+ description: row.description == null ? null : row.description,
+ wants: row.wants == null ? null : row.wants,
+ license: row.license,
+ license_url: row.license_url == null ? null : row.license_url,
+ version: row.version,
+ creator: row.creator,
+ status: row.status,
+ revision: Number(row.revision) || 1,
+ passport: wirePassport(row.passport),
+ manifest: wireManifest(row.manifest),
+ listed_at: iso(row.listed_at),
+ closed_at: iso(row.closed_at),
+ closed_reason: row.closed_reason == null ? null : row.closed_reason,
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ };
+ if (includeOwner) out.owner = row.owner == null ? null : row.owner;
+ if (summaryTests) {
+ const count = Number(row.test_count) || 0;
+ const failed = Number(row.test_failed) || 0;
+ out.tests = { count, failed, verified: count > 0 && failed === 0 };
+ }
+ if (tests) out.tests = { count: tests.length, failed: tests.filter((t) => t.status === 'failed').length, verified: tests.length > 0 && tests.every((t) => t.status === 'passed'), rows: tests };
+ if (row.install_count != null) out.installs = Number(row.install_count) || 0;
+ if (row.offer_count != null) out.open_offers = Number(row.offer_count) || 0;
+ return out;
+}
+
+function wireTrial(row, listing = null) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ listing_id: row.listing_id,
+ version: row.version,
+ status: row.status,
+ passport: wirePassport(row.passport),
+ checks: json(row.checks, []),
+ created_at: iso(row.created_at),
+ expires_at: iso(row.expires_at),
+ consumed_at: iso(row.consumed_at),
+ ...(listing ? { listing: { slug: listing.slug, title: listing.title, mode: listing.mode, version: listing.version } } : {}),
+ };
+}
+
+function wireInstall(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ listing_id: row.listing_id,
+ version: row.version,
+ status: row.status,
+ passport: wirePassport(row.passport),
+ installed_at: iso(row.installed_at),
+ revoked_at: iso(row.revoked_at),
+ revision: Number(row.revision) || 1,
+ ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license } } : {}),
+ };
+}
+
+function wireOffer(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ listing_id: row.listing_id,
+ kind: row.kind,
+ body: row.body,
+ give: row.give == null ? null : row.give,
+ from_name: row.from_name,
+ status: row.status,
+ created_at: iso(row.created_at),
+ decided_at: iso(row.decided_at),
+ ...(row.slug ? { listing: { slug: row.slug, title: row.title, mode: row.mode, owner: row.listing_owner || null } } : {}),
+ };
+}
+
+function normalizePassport(input) {
+ const p = input && typeof input === 'object' ? input : {};
+ const allowed = ['reads', 'writes', 'spends', 'contacts'];
+ const unknown = Object.keys(p).filter((k) => !allowed.includes(k));
+ if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown passport field: ${unknown[0]}`, 'passport');
+ const out = {};
+ for (const key of ['reads', 'writes']) {
+ const list = p[key] == null ? [] : p[key];
+ if (!Array.isArray(list)) return refusal(400, 'exchange.invalid', `passport.${key} must be an array of permission names`, 'passport');
+ if (list.length > 20) return refusal(400, 'exchange.invalid', `passport.${key} may name at most 20 permissions`, 'passport');
+ const seen = new Set();
+ const cleaned = [];
+ for (const value of list) {
+ const s = cleanText(value, 64);
+ if (!SCOPE_RE.test(s)) return refusal(400, 'exchange.invalid', `passport.${key} must be lower-case permission names such as search.read`, 'passport');
+ if (!seen.has(s)) { seen.add(s); cleaned.push(s); }
+ }
+ out[key] = cleaned;
+ }
+ const spends = p.spends == null ? [] : p.spends;
+ if (!Array.isArray(spends)) return refusal(400, 'exchange.invalid', 'passport.spends must be an array', 'passport');
+ if (spends.length) return refusal(400, 'exchange.no_money', 'v1 is free and swap only: a passport may not declare a spend', 'passport');
+ out.spends = [];
+ const contacts = p.contacts == null ? [] : p.contacts;
+ if (!Array.isArray(contacts)) return refusal(400, 'exchange.invalid', 'passport.contacts must be an array of https origins', 'passport');
+ if (contacts.length > 20) return refusal(400, 'exchange.invalid', 'passport.contacts may name at most 20 origins', 'passport');
+ out.contacts = [];
+ const seenContacts = new Set();
+ for (const value of contacts) {
+ const s = cleanText(value, 200);
+ if (!CONTACT_RE.test(s)) return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport');
+ try {
+ const u = new URL(s);
+ if (u.username || u.password || !u.hostname) throw new Error('credentials');
+ const normalized = `${u.origin}${u.pathname === '/' ? '' : u.pathname}`;
+ if (!seenContacts.has(normalized)) { seenContacts.add(normalized); out.contacts.push(normalized); }
+ } catch { return refusal(400, 'exchange.invalid', 'passport.contacts must be https origins, one per entry', 'passport'); }
+ }
+ return { ok: true, value: out };
+}
+
+function normalizeManifest(input, version) {
+ const m = input && typeof input === 'object' ? input : {};
+ const allowed = ['name', 'entry', 'runtime'];
+ const unknown = Object.keys(m).filter((k) => !allowed.includes(k));
+ if (unknown.length) return refusal(400, 'exchange.invalid', `A manifest may name only name, entry and runtime (not ${unknown[0]})`, 'manifest');
+ const name = cleanText(m.name, 80);
+ const entry = cleanText(m.entry, 200);
+ const runtime = cleanText(m.runtime, 80);
+ if (name.length < 2 || name.length > 80) return refusal(400, 'exchange.invalid', 'manifest.name must be 2 to 80 characters', 'manifest');
+ if (entry.length < 1 || entry.length > 200) return refusal(400, 'exchange.invalid', 'manifest.entry must be 1 to 200 characters', 'manifest');
+ if (runtime && (runtime.length < 1 || runtime.length > 80)) return refusal(400, 'exchange.invalid', 'manifest.runtime may be at most 80 characters', 'manifest');
+ for (const [key, value] of [['name', name], ['entry', entry], ['runtime', runtime]]) {
+ if (SECRET_VALUE_RE.test(value)) return refusal(400, 'exchange.manifest_secret', `manifest.${key} looks like a credential; a manifest is public metadata and must never carry a secret`, 'manifest');
+ if (/[\u0000-\u001f\u007f]/.test(value)) return refusal(400, 'exchange.invalid', 'a manifest is plain text: no control characters', 'manifest');
+ }
+ return { ok: true, value: { name, entry, runtime: runtime || null, version } };
+}
+
+/** Validate a new listing body. Returns { ok, value } or a refusal; the page form and the API share it. */
+function normalizeListing(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ const allowed = ['title', 'kind', 'mode', 'summary', 'description', 'license', 'license_url', 'version', 'wants', 'manifest', 'passport'];
+ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));
+ if (unknown.length) return refusal(400, 'exchange.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);
+ const title = cleanText(b.title, 120);
+ if (title.length < 3 || title.length > 120) return refusal(400, 'exchange.invalid', 'title must be 3 to 120 characters', 'title');
+ if (!KINDS.includes(b.kind)) return refusal(400, 'exchange.invalid', `kind must be one of ${KINDS.join(', ')}`, 'kind');
+ if (!MODES.includes(b.mode)) return refusal(400, 'exchange.invalid', `mode must be one of ${MODES.join(', ')}`, 'mode');
+ const summary = cleanText(b.summary, 600);
+ if (summary.length < 3 || summary.length > 600) return refusal(400, 'exchange.invalid', 'summary must be 3 to 600 characters', 'summary');
+ const description = cleanText(b.description, 4000);
+ const license = cleanText(b.license, 64);
+ if (!LICENSE_RE.test(license)) return refusal(400, 'exchange.invalid', 'license must be an SPDX identifier such as CC0-1.0, MIT or AGPL-3.0-only', 'license');
+ const licenseUrl = cleanText(b.license_url, 300);
+ if (licenseUrl && !/^https:\/\/[^\s"'<>]{1,300}$/.test(licenseUrl)) return refusal(400, 'exchange.invalid', 'license_url must be an https URL', 'license_url');
+ const version = cleanText(b.version, 32);
+ if (!VERSION_RE.test(version)) return refusal(400, 'exchange.invalid', 'version must be a semantic version such as 1.0.0', 'version');
+ const wants = cleanText(b.wants, 300);
+ if (b.mode === 'swap' && (wants.length < 3 || wants.length > 300)) return refusal(400, 'exchange.invalid', 'a swap listing must say what it would like in return (3 to 300 characters)', 'wants');
+ if (b.mode === 'free' && wants) return refusal(400, 'exchange.invalid', 'a free listing does not name what it wants in return', 'wants');
+ const manifest = normalizeManifest(b.manifest, version);
+ if (!manifest.ok) return manifest;
+ const passport = normalizePassport(b.passport);
+ if (!passport.ok) return passport;
+ const hit = refusals(title, summary, description, wants);
+ if (hit) return hit;
+ return {
+ ok: true,
+ value: {
+ title, kind: b.kind, mode: b.mode, summary, description: description || null, license,
+ license_url: licenseUrl || null, version, wants: wants || null, manifest: manifest.value, passport: passport.value,
+ },
+ };
+}
+
+/** The four conformance tests run for every version. A listing is not public until every one passed. */
+function checksFor(v) {
+ return [
+ { name: 'manifest', status: 'passed', detail: 'The manifest names the listing and its entry point; it carries no credential.' },
+ { name: 'passport', status: 'passed', detail: 'The permission passport is present and names only read, write and contact grants.' },
+ { name: 'licence', status: 'passed', detail: `The licence is ${v.license}.` },
+ { name: 'no_money', status: 'passed', detail: `Mode ${v.mode}; the passport declares no spend. ${SAFETY}` },
+ ];
+}
+
+function slugify(title) {
+ const s = String(title || '')
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 70);
+ return s || 'listing';
+}
+
+function createStore(s, { search, config }) {
+ const db = s.db;
+ const q = (t) => t || db;
+ const sandboxTtlMs = Math.max(5, Number(config && config.sandbox && config.sandbox.ttlMinutes) || 60) * 60_000;
+ const sandboxPerDay = Math.max(1, Number(config && config.sandbox && config.sandbox.perDay) || 20);
+
+ async function bySlug(slug, t) {
+ return await q(t).maybe('SELECT * FROM exchange_listings WHERE slug = $1', [String(slug || '')]);
+ }
+ async function byId(id, t) {
+ return await q(t).maybe('SELECT * FROM exchange_listings WHERE id = $1', [String(id || '')]);
+ }
+ async function testsFor(listingId, version = null, t) {
+ const rows = version
+ ? await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 AND version = $2 ORDER BY id ASC', [listingId, version])
+ : await q(t).many('SELECT * FROM exchange_listing_tests WHERE listing_id = $1 ORDER BY id ASC', [listingId]);
+ return rows.map(wireTest);
+ }
+ async function verified(listingId, version, t) {
+ const rows = await testsFor(listingId, version, t);
+ return rows.length > 0 && rows.every((r) => r.status === 'passed');
+ }
+
+ async function uniqueSlug(base, t) {
+ const root = String(base || 'listing').slice(0, 70).replace(/^-+|-+$/g, '') || 'listing';
+ let slug = root;
+ for (let i = 2; i < 200; i++) {
+ const found = await q(t).maybe('SELECT id FROM exchange_listings WHERE slug = $1', [slug]);
+ if (!found) return slug;
+ slug = `${root}-${i}`;
+ }
+ return `${root}-${Date.now().toString(36)}`;
+ }
+
+ async function event(t, listingId, from, to, reason, changedBy) {
+ await t.exec('INSERT INTO exchange_listing_events (listing_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, $3, $4, $5)',
+ [listingId, from, to, reason || null, changedBy || null]);
+ }
+
+ /** Insert one draft and its conformance tests in the caller's transaction. Never publishes. */
+ async function createListing({ owner, creator, body }, t = null) {
+ const checked = normalizeListing(body);
+ if (!checked.ok) return checked;
+ const v = checked.value;
+ const run = async (tx) => {
+ const id = s.newId('exl');
+ const slug = await uniqueSlug(slugify(v.title), tx);
+ const creatorName = cleanText(creator, 80) || 'a maker';
+ const row = await tx.maybe(
+ `INSERT INTO exchange_listings (id, slug, title, kind, mode, summary, description, wants, license, license_url, version,
+ creator, owner, status, manifest, passport, revision, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'draft', $14::jsonb, $15::jsonb, 1, now(), now())
+ RETURNING *`,
+ [id, slug, v.title, v.kind, v.mode, v.summary, v.description, v.wants, v.license, v.license_url, v.version,
+ creatorName, owner, JSON.stringify(v.manifest), JSON.stringify(v.passport)],
+ );
+ const checks = checksFor(v);
+ for (const c of checks) {
+ await tx.exec('INSERT INTO exchange_listing_tests (listing_id, version, name, status, detail) VALUES ($1, $2, $3, $4, $5)',
+ [id, v.version, c.name, c.status, c.detail]);
+ }
+ await event(tx, id, null, 'draft', 'created', owner);
+ const tests = await testsFor(id, v.version, tx);
+ return { ok: true, listing: wireListing(row, { tests, includeOwner: true }) };
+ };
+ return t ? await run(t) : await s.tx(run);
+ }
+
+ /** Public listings, newest first. cursor is an opaque (listed_at, id) keyset position. */
+ async function list({ limit = 20, cursor = null, mode = null, kind = null, q: text = null } = {}) {
+ const n = Math.min(50, Math.max(1, Number(limit) || 20));
+ const args = [];
+ let where = "status = 'listed'";
+ if (mode) { if (!MODES.includes(mode)) return { error: 'mode' }; args.push(mode); where += ` AND mode = $${args.length}`; }
+ if (kind) { if (!KINDS.includes(kind)) return { error: 'kind' }; args.push(kind); where += ` AND kind = $${args.length}`; }
+ const term = cleanText(text, 120);
+ if (term) {
+ args.push(`%${term.replace(/[%_\\]/g, (c) => `\\${c}`)}%`);
+ where += ` AND (title ILIKE $${args.length} OR summary ILIKE $${args.length} OR creator ILIKE $${args.length})`;
+ }
+ let pos = null;
+ if (cursor) {
+ try {
+ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
+ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;
+ } catch { /* refused below */ }
+ if (!pos) return { error: 'cursor' };
+ args.push(pos.t, pos.i);
+ where += ` AND (listed_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;
+ }
+ args.push(n + 1);
+ const rows = await db.many(
+ `SELECT l.*,
+ (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'passed') AS test_count,
+ (SELECT COUNT(*)::int FROM exchange_listing_tests t WHERE t.listing_id = l.id AND t.version = l.version AND t.status = 'failed') AS test_failed
+ FROM exchange_listings l WHERE ${where} ORDER BY listed_at DESC, id DESC LIMIT $${args.length}`,
+ args,
+ );
+ const hasMore = rows.length > n;
+ const page = rows.slice(0, n);
+ const last = page[page.length - 1];
+ return {
+ listings: page.map((r) => wireListing(r, { summaryTests: true })),
+ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.listed_at), i: last.id })).toString('base64url') : null,
+ };
+ }
+
+ /** One listing with its conformance tests and public counters. */
+ async function detail(slug) {
+ const row = await bySlug(slug);
+ if (!row) return null;
+ const enriched = await db.maybe(
+ `SELECT l.*,
+ (SELECT COUNT(*)::int FROM exchange_installs i WHERE i.listing_id = l.id AND i.status = 'active') AS install_count,
+ (SELECT COUNT(*)::int FROM exchange_offers o WHERE o.listing_id = l.id AND o.status = 'open') AS offer_count
+ FROM exchange_listings l WHERE l.id = $1`,
+ [row.id],
+ );
+ return { row: enriched, listing: wireListing(enriched, { tests: await testsFor(row.id), includeOwner: true }) };
+ }
+
+ async function listMine(owner, { limit = 100 } = {}) {
+ const rows = await db.many('SELECT * FROM exchange_listings WHERE owner = $1 ORDER BY created_at DESC LIMIT $2', [owner, Math.min(200, Math.max(1, Number(limit) || 100))]);
+ const out = [];
+ for (const row of rows) out.push(wireListing(row, { tests: await testsFor(row.id) }));
+ return out;
+ }
+
+ async function counts() {
+ const row = await db.maybe(
+ `SELECT COUNT(*)::int AS listings,
+ COUNT(*) FILTER (WHERE mode = 'free')::int AS free,
+ COUNT(*) FILTER (WHERE mode = 'swap')::int AS swap,
+ (SELECT COUNT(DISTINCT owner)::int FROM exchange_listings WHERE status = 'listed' AND owner IS NOT NULL) AS makers,
+ (SELECT COUNT(*)::int FROM exchange_installs WHERE status = 'active') AS installs
+ FROM exchange_listings WHERE status = 'listed'`,
+ );
+ return row || { listings: 0, free: 0, swap: 0, makers: 0, installs: 0 };
+ }
+
+ /** Owner publishes a draft (or a closed one cannot be relisted). Every conformance test must have passed. */
+ async function publish(slug, { owner } = {}) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');
+ if (row.status === 'listed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
+ if (row.status === 'hidden') return refusal(409, 'exchange.hidden', 'Staff hid this listing; it cannot be relisted here.');
+ if (row.status === 'closed') return refusal(409, 'exchange.closed', 'A closed listing cannot be relisted; submit a new one.');
+ if (!(await verified(row.id, row.version, t))) return refusal(409, 'exchange.tests_not_passed', 'Every conformance test for this version must pass before the listing can be public.');
+ const updated = await t.maybe("UPDATE exchange_listings SET status = 'listed', listed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);
+ await event(t, row.id, row.status, 'listed', 'listed', owner);
+ await search.sync(t, wireListing(updated), []);
+ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
+ });
+ }
+
+ async function close(slug, { owner, reason = 'withdrawn' } = {}) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row || row.owner !== owner) return refusal(404, 'exchange.not_found', 'No such listing of yours.');
+ if (row.status === 'closed') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
+ const why = CLOSED_REASONS.includes(reason) ? reason : 'withdrawn';
+ const updated = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id, why]);
+ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND status = 'open'", [row.id]);
+ await event(t, row.id, row.status, 'closed', why, owner);
+ await search.sync(t, wireListing(updated), []);
+ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
+ });
+ }
+
+ async function hide(slug, { changedBy } = {}) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row) return refusal(404, 'exchange.not_found', 'No such listing.');
+ if (row.status === 'hidden') return { ok: true, listing: wireListing(row, { tests: await testsFor(row.id), includeOwner: true }), unchanged: true };
+ const updated = await t.maybe("UPDATE exchange_listings SET status = 'hidden', revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [row.id]);
+ await event(t, row.id, row.status, 'hidden', 'staff hide', changedBy);
+ await search.sync(t, wireListing(updated), []);
+ return { ok: true, listing: wireListing(updated, { tests: await testsFor(row.id), includeOwner: true }), unchanged: false };
+ });
+ }
+
+ /** A sandbox trial: a manifest dry run against the passport; no listing code is executed. */
+ async function sandbox(slug, { subject }) {
+ const row = await bySlug(slug);
+ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
+ const tests = await testsFor(row.id, row.version);
+ const dayStart = new Date(s.now());
+ dayStart.setUTCHours(0, 0, 0, 0);
+ const used = Number(await db.value('SELECT COUNT(*)::int FROM exchange_sandbox_trials WHERE subject = $1 AND created_at >= $2::timestamptz', [subject, dayStart.toISOString()])) || 0;
+ if (used >= sandboxPerDay) return refusal(429, 'exchange.sandbox_limit', 'You have tried as many listings in the sandbox today as v1 allows; try again tomorrow.');
+ const passport = wirePassport(row.passport);
+ const verifiedTests = tests.length > 0 && tests.every((x) => x.status === 'passed');
+ const checks = [
+ { name: 'listed', status: 'passed', detail: 'The listing is public.' },
+ { name: 'tests', status: verifiedTests ? 'passed' : 'failed', detail: verifiedTests ? `${tests.length} conformance tests passed for version ${row.version}.` : 'The conformance tests for this version are not all passing.' },
+ { name: 'passport', status: passport.spends.length === 0 ? 'passed' : 'failed', detail: passport.spends.length === 0 ? 'The passport declares read, write and contact grants only; no spend.' : 'The passport declares a spend, which v1 does not allow.' },
+ { name: 'execution', status: 'passed', detail: 'Dry run only: Exchange v1 does not execute the listing. A real Run sandbox needs the run.sandbox.execute grant (README).' },
+ ];
+ const status = checks.every((c) => c.status === 'passed') ? 'passed' : 'failed';
+ const id = s.newId('exs');
+ const expiresAt = new Date(s.now() + sandboxTtlMs).toISOString();
+ const saved = await db.maybe(
+ `INSERT INTO exchange_sandbox_trials (id, listing_id, subject, version, status, passport, checks, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, now(), $8::timestamptz) RETURNING *`,
+ [id, row.id, subject, row.version, status, JSON.stringify(passport), JSON.stringify(checks), expiresAt],
+ );
+ return { ok: true, trial: wireTrial(saved, row), checks, listing: wireListing(row) };
+ }
+
+ async function trialById(id, { subject }) {
+ const row = await db.maybe(
+ `SELECT t.*, l.slug, l.title, l.mode, l.version AS listing_version FROM exchange_sandbox_trials t
+ JOIN exchange_listings l ON l.id = t.listing_id WHERE t.id = $1 AND t.subject = $2`,
+ [String(id || ''), subject],
+ );
+ if (!row) return null;
+ const listing = { slug: row.slug, title: row.title, mode: row.mode, version: row.listing_version };
+ return { row, trial: wireTrial(row, listing), listing };
+ }
+
+ async function hasPassedTrial(listingId, subject, version) {
+ const row = await db.maybe(
+ `SELECT id FROM exchange_sandbox_trials
+ WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz
+ ORDER BY created_at DESC LIMIT 1`,
+ [listingId, subject, version, s.iso()],
+ );
+ return Boolean(row);
+ }
+
+ async function activeInstall(listingId, subject) {
+ const row = await db.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [listingId, subject]);
+ return wireInstall(row);
+ }
+
+ async function install(slug, { subject }) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
+ const existing = await t.maybe("SELECT * FROM exchange_installs WHERE listing_id = $1 AND subject = $2 AND status = 'active'", [row.id, subject]);
+ if (existing) return { ok: true, install: wireInstall({ ...existing, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: true };
+ const trial = await t.maybe(
+ `SELECT * FROM exchange_sandbox_trials
+ WHERE listing_id = $1 AND subject = $2 AND version = $3 AND status = 'passed' AND consumed_at IS NULL AND expires_at > $4::timestamptz
+ ORDER BY created_at DESC LIMIT 1`,
+ [row.id, subject, row.version, s.iso()],
+ );
+ if (!trial) return refusal(409, 'exchange.sandbox_required', 'Try this version in the sandbox first; a passed trial is required before an install.');
+ const id = s.newId('exi');
+ const saved = await t.maybe(
+ `INSERT INTO exchange_installs (id, listing_id, subject, version, passport, status, installed_at, revision)
+ VALUES ($1, $2, $3, $4, $5::jsonb, 'active', now(), 1) RETURNING *`,
+ [id, row.id, subject, row.version, JSON.stringify(wirePassport(row.passport))],
+ );
+ await t.exec('UPDATE exchange_sandbox_trials SET consumed_at = now() WHERE id = $1', [trial.id]);
+ return { ok: true, install: wireInstall({ ...saved, slug: row.slug, title: row.title, mode: row.mode, kind: row.kind, license: row.license }), unchanged: false };
+ });
+ }
+
+ async function installsFor(subject) {
+ const rows = await db.many(
+ `SELECT i.*, l.slug, l.title, l.mode, l.kind, l.license FROM exchange_installs i
+ JOIN exchange_listings l ON l.id = i.listing_id WHERE i.subject = $1 ORDER BY i.installed_at DESC`,
+ [subject],
+ );
+ return rows.map(wireInstall);
+ }
+
+ async function revoke(installId, { subject }) {
+ return await s.tx(async (t) => {
+ const row = await t.maybe('SELECT * FROM exchange_installs WHERE id = $1 AND subject = $2', [String(installId || ''), subject]);
+ if (!row) return refusal(404, 'exchange.install_not_found', 'No such installation of yours.');
+ if (row.status === 'revoked') return { ok: true, install: wireInstall(row), unchanged: true };
+ const updated = await t.maybe("UPDATE exchange_installs SET status = 'revoked', revoked_at = now(), revision = revision + 1 WHERE id = $1 RETURNING *", [row.id]);
+ // v1 has no Network grant to revoke: this record is the entitlement. v2 calls Network's grant revoke
+ // with the exchange service token (README, "Grants it will need").
+ return { ok: true, install: wireInstall(updated), unchanged: false };
+ });
+ }
+
+ /** A free claim or a swap offer. The owner cannot offer on their own listing. */
+ async function offer(slug, { subject, fromName, body, give }) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row || row.status !== 'listed') return refusal(404, 'exchange.not_found', 'No such public listing.');
+ if (row.owner === subject) return refusal(400, 'exchange.own_listing', 'This is your listing; you cannot claim or swap with yourself.');
+ const existing = await t.maybe("SELECT id FROM exchange_offers WHERE listing_id = $1 AND from_subject = $2 AND status = 'open'", [row.id, subject]);
+ if (existing) return refusal(409, 'exchange.offer_exists', 'You already have an open offer on this listing.');
+ const kind = row.mode === 'free' ? 'claim' : 'swap';
+ const message = cleanText(body, 1000);
+ if (message.length < 3 || message.length > 1000) return refusal(400, 'exchange.invalid', 'Say something to the maker (3 to 1000 characters).', 'body');
+ const giving = cleanText(give, 1000);
+ if (kind === 'swap' && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A swap offer must say what you offer in return (3 to 1000 characters).', 'give');
+ if (kind === 'claim' && giving && (giving.length < 3 || giving.length > 1000)) return refusal(400, 'exchange.invalid', 'A claim may include a note of 3 to 1000 characters, or nothing at all.', 'give');
+ const hit = refusals(message, giving);
+ if (hit) return hit;
+ const id = s.newId('exo');
+ const saved = await t.maybe(
+ `INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body, give, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'open', now()) RETURNING *`,
+ [id, row.id, subject, cleanText(fromName, 80) || 'a maker', kind, message, giving || null],
+ );
+ return { ok: true, offer: wireOffer({ ...saved, slug: row.slug, title: row.title, mode: row.mode, listing_owner: row.owner }) };
+ });
+ }
+
+ async function offersFor(subject) {
+ const sent = await db.many(
+ `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o
+ JOIN exchange_listings l ON l.id = o.listing_id WHERE o.from_subject = $1 ORDER BY o.created_at DESC`,
+ [subject],
+ );
+ const received = await db.many(
+ `SELECT o.*, l.slug, l.title, l.mode, l.owner AS listing_owner FROM exchange_offers o
+ JOIN exchange_listings l ON l.id = o.listing_id WHERE l.owner = $1 ORDER BY o.created_at DESC`,
+ [subject],
+ );
+ return { sent: sent.map(wireOffer), received: received.map(wireOffer) };
+ }
+
+ async function offersForListing(listingId) {
+ const rows = await db.many("SELECT * FROM exchange_offers WHERE listing_id = $1 AND status = 'open' ORDER BY created_at ASC", [listingId]);
+ return rows.map(wireOffer);
+ }
+
+ /** accept/decline are the owner's; withdraw is the offerer's. Accept closes the listing (given or swapped). */
+ async function decideOffer(offerId, { subject, action }) {
+ if (!['accept', 'decline', 'withdraw'].includes(action)) return refusal(400, 'exchange.invalid', 'action must be accept, decline or withdraw');
+ return await s.tx(async (t) => {
+ const o = await t.maybe('SELECT * FROM exchange_offers WHERE id = $1', [String(offerId || '')]);
+ if (!o) return refusal(404, 'exchange.offer_not_found', 'No such offer.');
+ const listing = await byId(o.listing_id, t);
+ if (!listing) return refusal(404, 'exchange.offer_not_found', 'No such offer.');
+ if (action === 'withdraw' && o.from_subject !== subject) return refusal(403, 'exchange.offer_not_yours', 'Only the person who made an offer can withdraw it.');
+ if (action !== 'withdraw' && listing.owner !== subject) return refusal(403, 'exchange.owner_only', 'Only the listing owner can accept or decline an offer.');
+ if (o.status !== 'open') return { ok: true, offer: wireOffer(o), unchanged: true };
+ const status = action === 'accept' ? 'accepted' : action === 'decline' ? 'declined' : 'withdrawn';
+ const updated = await t.maybe("UPDATE exchange_offers SET status = $2, decided_at = now() WHERE id = $1 RETURNING *", [o.id, status]);
+ let closed = null;
+ if (status === 'accepted') {
+ const why = listing.mode === 'free' ? 'given' : 'swapped';
+ const updatedListing = await t.maybe("UPDATE exchange_listings SET status = 'closed', closed_reason = $2, closed_at = now(), revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *", [listing.id, why]);
+ await t.exec("UPDATE exchange_offers SET status = 'declined', decided_at = now() WHERE listing_id = $1 AND id <> $2 AND status = 'open'", [listing.id, o.id]);
+ await event(t, listing.id, listing.status, 'closed', why, subject);
+ await search.sync(t, wireListing(updatedListing), []);
+ closed = wireListing(updatedListing, { tests: await testsFor(listing.id), includeOwner: true });
+ }
+ return { ok: true, offer: wireOffer(updated), closed, unchanged: false };
+ });
+ }
+
+ /** Imported/seedable listing used by scripts/seed-proof.js: goes through the same validation and tests. */
+ async function seedListing({ owner, creator, body }) {
+ return await createListing({ owner, creator, body });
+ }
+
+ return {
+ wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,
+ normalizeListing, normalizePassport, normalizeManifest, checksFor,
+ bySlug, byId, testsFor, verified, uniqueSlug,
+ createListing, list, detail, listMine, counts,
+ publish, close, hide,
+ sandbox, trialById,
+ hasPassedTrial, activeInstall, install, installsFor, revoke,
+ offer, offersFor, offersForListing, decideOffer,
+ seedListing,
+ KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, SAFETY,
+ };
+}
+
+module.exports = {
+ createStore, slugify, normalizeListing, normalizePassport, normalizeManifest, checksFor,
+ wireListing, wireTrial, wireInstall, wireOffer, wirePassport, wireTest,
+ SLUG_RE, KINDS, MODES, STATUSES, CLOSED_REASONS, OFFER_KINDS, OFFER_STATUSES, VERSION_RE, LICENSE_RE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community, MediaHub and
+ * Meme): an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. Exchange
+ * v1 has no AI-written copy — a listing's words are the maker's own — but noLooks is here for the day an AI
+ * summary, sandbox description or catalogue line is generated. A summary that names looks is replaced by the
+ * listing's title, exactly as Live and Meme do; a maker's own verbatim text is never rewritten by this module.
+ */
+const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');
+
+/** The summary to store for an AI-written listing summary. */
+function cleanSummary(title, summary) {
+ const t = String(title || '').trim() || 'Untitled listing';
+ const s = String(summary == null ? '' : summary).trim();
+ if (!s) return t;
+ if (hasLooks(s)) return t;
+ const cleaned = String(noLooks(s) || '').trim();
+ return cleaned || t;
+}
+
+module.exports = { cleanSummary, noLooks, hasLooks };
new file mode 100644
new file mode 100644
@@ -0,0 +1,432 @@
@@ -0,0 +1,432 @@
+'use strict';
+
+/**
+ * The Exchange room's pages (plan 2026-10-28 §5.11). Server-rendered and whole without JavaScript: every action is a
+ * plain form or link. public/js/exchange.js and public/css/exchange.css are one-comment placeholders the main
+ * session replaces; the sandbox, install, revoke and offers work without them.
+ *
+ * home({ listings, counts }) the hero, the three scenes, public listings
+ * listingsPage({ listings, filters }) browse and filter
+ * listingPage({ listing, tests, ... }) passport, tests, sandbox, install/revoke and offers
+ * submitForm({ values, mine, problem }) create a listing (draft)
+ * sandboxPage({ trial, listing }) one sandbox trial and its checks
+ * installsPage({ installs }) the caller's installations
+ * offersPage({ sent, received }) free claims and swap offers
+ * safetyPage() free and swap safety rules
+ * developersPage() the API
+ * signIn({ title, text, next }) a page that needs an account
+ * notFound()
+ * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are
+ * stable and start with `ex-`.
+ */
+const { esc, time } = require('../render/html');
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+
+const KIND_LABEL = { agent: 'Agent', mcp_tool: 'MCP tool', template: 'Template', theme: 'Theme or kit', game_asset: 'Game asset', service: 'Creator service' };
+const MODE_LABEL = { free: 'Free', swap: 'Swap' };
+const STATUS_LABEL = { draft: 'Draft', listed: 'Listed', hidden: 'Hidden', closed: 'Closed' };
+const CLOSED_LABEL = { given: 'Given away', swapped: 'Swapped', withdrawn: 'Withdrawn' };
+const PASSPORT_LABEL = { reads: 'Can read', writes: 'Can write', spends: 'Can spend', contacts: 'Can contact' };
+
+const kindLabel = (k) => esc(KIND_LABEL[k] || k);
+const modeBadge = (m) => `<span class="ex-mode ex-mode-${esc(m)}">${esc(MODE_LABEL[m] || m)}</span>`;
+const statusBadge = (s) => `<span class="ex-status ex-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
+
+function verifiedBadge(listing) {
+ if (!listing || !listing.tests) return '';
+ if (listing.tests.verified) return `<span class="ex-verified" title="Every conformance test for this version passed">✓ tests pass</span>`;
+ return `<span class="ex-unverified">${Number(listing.tests.failed) ? 'tests failed' : 'not tested'}</span>`;
+}
+
+function card(l) {
+ return `<li class="ex-listing-card" data-listing="${esc(l.slug)}" data-kind="${esc(l.kind)}" data-mode="${esc(l.mode)}">
+<a class="ex-listing-link" href="/listings/${esc(l.slug)}">
+<span class="ex-listing-top">${modeBadge(l.mode)}<span class="ex-kind">${kindLabel(l.kind)}</span>${verifiedBadge(l)}</span>
+<span class="ex-listing-title">${esc(l.title)}</span>
+<span class="ex-listing-summary">${esc(l.summary)}</span>
+<span class="ex-listing-meta"><span class="ex-creator">${esc(l.creator)}</span> · <span class="ex-license">${esc(l.license)}</span> · <span class="ex-version">v${esc(l.version)}</span></span>
+</a></li>`;
+}
+
+function empty(text, href = '/submit', label = 'List something') {
+ return `<div class="ex-empty"><p>${esc(text)}</p><a class="ex-btn ex-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;
+}
+
+function passport(p = {}, { id = 'ex-passport' } = {}) {
+ const rows = ['reads', 'writes', 'spends', 'contacts'];
+ const value = (key) => {
+ const list = Array.isArray(p[key]) ? p[key] : [];
+ if (!list.length) return key === 'spends' ? '<span class="ex-none">Nothing — v1 has no money</span>' : '<span class="ex-none">Nothing</span>';
+ return `<ul class="ex-passport-list">${list.map((x) => `<li>${key === 'contacts' ? `<code>${esc(x)}</code>` : `<code>${esc(x)}</code>`}</li>`).join('')}</ul>`;
+ };
+ return `<section class="ex-passport"${id ? ` id="${id}"` : ''} aria-label="Permission passport">
+<div class="ex-section-head"><h2>Permission passport</h2><span class="ex-passport-stamp" aria-hidden="true">passport</span></div>
+<p class="ex-fine">Exactly what this listing declares it can read, write, spend and contact. The passport is checked in the sandbox and snapshotted at install; a later edit to the listing cannot widen an old install.</p>
+<dl class="ex-passport-grid">
+${rows.map((key) => `<div class="ex-passport-row ex-passport-${key}"><dt>${esc(PASSPORT_LABEL[key])}</dt><dd>${value(key)}</dd></div>`).join('')}
+</dl>
+</section>`;
+}
+
+function testsBlock(tests = []) {
+ return `<section class="ex-tests-sec" id="ex-tests" aria-labelledby="ex-tests-h">
+<div class="ex-section-head"><h2 id="ex-tests-h">Tests for this version</h2>${tests.length && tests.every((t) => t.status === 'passed') ? '<span class="ex-verified">✓ all pass</span>' : '<span class="ex-unverified">not passing</span>'}</div>
+${tests.length ? `<ul class="ex-tests">${tests.map((t) => `<li class="ex-test ex-test-${esc(t.status)}"><span class="ex-test-name"><b>${esc(t.name)}</b> <span class="ex-test-status">${esc(t.status)}</span></span><span class="ex-test-detail">${esc(t.detail || '')}</span></li>`).join('')}</ul>` : '<p class="ex-muted">No conformance test has been recorded for this version.</p>'}
+</section>`;
+}
+
+/** The room's picture: two arrows looping an item between makers, and a passport stamping. */
+function motif() {
+ return `<div class="ex-motif" aria-hidden="true">
+<svg class="ex-motif-arrows" viewBox="0 0 420 260" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M84 92c32-46 92-62 148-42 30 11 54 34 68 62" stroke="currentColor" stroke-width="3" stroke-linecap="round"/>
+<path d="m286 96 16 18-24 6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M336 168c-32 46-92 62-148 42-30-11-54-34-68-62" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity=".55"/>
+<path d="m134 164-16-18 24-6" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" opacity=".55"/>
+</svg>
+<span class="ex-motif-item ex-motif-item-a">a template</span>
+<span class="ex-motif-item ex-motif-item-b">a theme</span>
+<span class="ex-motif-passport"><small>passport</small><b>safe</b></span>
+</div>`;
+}
+
+function home({ listings = [], counts = {} } = {}) {
+ const live = `<section class="ex-section" id="ex-listings" aria-labelledby="ex-listings-h">
+<div class="ex-section-head"><h2 id="ex-listings-h">Fresh listings</h2><a class="ex-more" href="/listings">All listings${kit.icon('chevron')}</a></div>
+${listings.length ? `<ol class="ex-grid">${listings.slice(0, 8).map(card).join('')}</ol>` : empty('Nothing is listed yet. The first proof is a licensed template, tried in the sandbox, installed and revoked.')}
+</section>`;
+ const room = sc.room({
+ room: 'exchange',
+ hero: {
+ title: 'Trade what people make',
+ lede: 'Agents, MCP tools, templates, themes, game assets and creator services — each with a permission passport that says exactly what it can read, write, spend and contact. Try it in the sandbox, install it with those grants, revoke it in one tap. Free and swap only in v1: there is no money.',
+ start: { href: '/listings', label: 'Browse the listings' },
+ chips: [{ label: 'List something', href: '/submit' }, { label: 'Safety rules', href: '/safety' }, { label: 'The API', href: '/developers' }],
+ proof: Number(counts.listings) ? [
+ { value: Number(counts.listings), label: Number(counts.listings) === 1 ? 'listing' : 'listings' },
+ { value: Number(counts.free) || 0, label: 'free' },
+ { value: Number(counts.swap) || 0, label: 'swap' },
+ { value: Number(counts.makers) || 0, label: Number(counts.makers) === 1 ? 'maker' : 'makers' },
+ ] : [],
+ motif: motif(),
+ },
+ scenes: [
+ {
+ title: 'Try it in the sandbox',
+ text: 'A sandbox trial reads the listing\'s manifest and checks its passport before anything is installed. v1 is an honest dry run: Exchange does not execute a listing\'s code, and says so. A passed trial is good for one hour and is required before an install.',
+ html: `<div class="ex-demo ex-demo-sandbox"><span class="ex-demo-head">sandbox trial</span><ul class="ex-demo-checks"><li>✓ manifest names the entry point</li><li>✓ passport declares no spend</li><li>✓ 4 conformance tests passed</li><li class="ex-muted">dry run: no listing code runs</li></ul></div>`,
+ action: { href: '/listings', label: 'Find something to try' },
+ },
+ {
+ title: 'Install it and see its passport grants',
+ text: 'The install keeps the exact passport it was made with: read, write and contact grants, and no spend in v1. The listing page shows the same passport before and after, so nothing arrives hidden.',
+ html: `<div class="ex-demo ex-demo-passport"><span class="ex-demo-head">permission passport</span><ul class="ex-demo-grants"><li><b>reads</b> search.read · space.files.read</li><li><b>writes</b> space.files.write</li><li><b>spends</b> nothing</li><li><b>contacts</b> api.example.com</li></ul><span class="ex-demo-stamp">installed</span></div>`,
+ action: { href: '/installs', label: 'See your installations' },
+ },
+ {
+ title: 'Revoke it in one tap',
+ text: 'A revoke marks the installation revoked on the spot and removes the passport with it. In v1 the Exchange install record is the entitlement; v2 also calls Network\'s grant revoke, so the access disappears everywhere at once.',
+ html: `<div class="ex-demo ex-demo-revoke"><span class="ex-demo-head">your installation</span><span class="ex-demo-row"><b>A starter site</b><span class="ex-ok">active</span></span><span class="ex-demo-row ex-muted">passport snapshot · 2026-10-28</span><span class="ex-demo-button">Revoke</span></div>`,
+ action: { href: '/installs', label: 'Manage installations' },
+ },
+ ],
+ faq: {
+ items: [
+ ['What is a permission passport?', 'A listing\'s public declaration of what it can read, write, spend and contact, taken from its manifest. It is shown on every listing, checked in the sandbox and snapshotted at install. In v1 a passport may not declare a spend at all.'],
+ ['Is anything paid?', 'No. v1 is free and swap only: no prices, no Billing, no cash, no Vibes, and no way to pay through Exchange. A listing or offer that asks for money is refused.'],
+ ['What does the sandbox actually run?', 'In v1, nothing of the listing\'s. It is a manifest and passport dry run that records its checks. A real Run sandbox is the next version\'s job; the capability it will need is named in the README.'],
+ ['How do I give something away or swap it?', 'List it as free or swap. A free listing takes claims; a swap listing asks what you would like in return and takes offers. Accepting one closes the listing as given or swapped.'],
+ ['Can I take something back?', 'Yes. Revoke an installation in one tap from Installations. Closing a listing stops new offers and removes it from search and the public list; an install you already made stays until you revoke it.'],
+ ],
+ },
+ });
+ const at = room.indexOf('<section class="sc-room-scenes"');
+ return `<div class="ex-wrap ex-home" id="ex-home">${at < 0 ? room : room.slice(0, at) + live + room.slice(at)}</div>`;
+}
+
+function filtersBar(filters = {}) {
+ const f = { q: '', mode: '', kind: '', ...filters };
+ return `<form class="ex-filters" id="ex-filters" method="get" action="/listings" role="search">
+<label class="ex-field"><span>Search</span><input type="search" name="q" value="${esc(f.q)}" maxlength="120" placeholder="a template, a theme, a maker…"></label>
+<label class="ex-field"><span>Mode</span><select name="mode"><option value="">Free and swap</option><option value="free"${f.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${f.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>
+<label class="ex-field"><span>Kind</span><select name="kind"><option value="">Every kind</option>${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${f.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
+<button class="ex-btn" type="submit">Filter</button>
+</form>`;
+}
+
+function listingsPage({ listings = [], nextCursor = null, filters = {} } = {}) {
+ const q = filters.q ? `&q=${encodeURIComponent(filters.q)}` : '';
+ const mode = filters.mode ? `&mode=${encodeURIComponent(filters.mode)}` : '';
+ const kind = filters.kind ? `&kind=${encodeURIComponent(filters.kind)}` : '';
+ return `<div class="ex-wrap" id="ex-listings-page">
+<header class="ex-page-h"><p class="ex-kicker">The marketplace</p><h1>Everything people make</h1>
+<p class="ex-lede">Free and swap only in v1. Every listing carries its permission passport, its licence and the conformance tests for its version. Try before you install; revoke any time.</p></header>
+${filtersBar(filters)}
+${listings.length ? `<ol class="ex-grid">${listings.map(card).join('')}</ol>` : empty('No listing matches this filter yet.', '/listings', 'Clear the filters')}
+${nextCursor ? `<p class="ex-more-row"><a class="ex-btn ex-btn-quiet" href="/listings?cursor=${encodeURIComponent(nextCursor)}${q}${mode}${kind}">More listings</a></p>` : ''}
+</div>`;
+}
+
+function offerList(offers = [], { owner = false } = {}) {
+ if (!offers.length) return '<p class="ex-muted">No open claim or offer yet.</p>';
+ return `<ul class="ex-offers-list">${offers.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}">
+<div class="ex-offer-head"><b class="ex-offer-from">${esc(o.from_name)}</b><span class="ex-offer-kind">${o.kind === 'claim' ? 'claims this' : 'offers a swap'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div>
+<p class="ex-offer-body">${esc(o.body)}</p>
+${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}
+${owner ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept${o.kind === 'claim' ? ' the claim' : ' the swap'}</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>`}
+</li>`).join('')}</ul>`;
+}
+
+function listingPage({
+ listing: l, tests = [], mine = false, staff = false, signedIn = false, activeInstall = null,
+ trialPassed = false, offers = [], received = false, problem = null, notice = null,
+} = {}) {
+ const offersOpen = l.status === 'listed';
+ const ownerPanel = mine ? `<section class="ex-owner" id="ex-owner" aria-labelledby="ex-owner-h">
+<div class="ex-section-head"><h2 id="ex-owner-h">Your listing</h2>${statusBadge(l.status)}</div>
+<p class="ex-fine">A draft becomes public only when every conformance test for this version has passed. Closing it stops new offers and removes it from the public list; a closed listing cannot be relisted.</p>
+${l.status === 'draft' ? `<form method="post" action="/listings/${esc(l.slug)}/list" class="ex-inline"><button class="ex-btn" type="submit"${l.tests && l.tests.verified ? '' : ' disabled'}>List it publicly</button></form>` : ''}
+${l.status === 'draft' && !(l.tests && l.tests.verified) ? '<p class="ex-note is-bad">Fix the failed tests before this can be public.</p>' : ''}
+${l.status === 'listed' ? `<form method="post" action="/listings/${esc(l.slug)}/close" class="ex-inline"><input type="hidden" name="reason" value="withdrawn"><button class="ex-btn ex-btn-quiet" type="submit">Close the listing</button></form>` : ''}
+${l.status === 'closed' ? `<p class="ex-muted">Closed: ${esc(CLOSED_LABEL[l.closed_reason] || l.closed_reason || 'closed')}.</p>` : ''}
+</section>` : '';
+
+ const sandbox = offersOpen ? `<section class="ex-sandbox-sec" id="ex-sandbox" aria-labelledby="ex-sandbox-h">
+<div class="ex-section-head"><h2 id="ex-sandbox-h">Try it in the sandbox</h2>${trialPassed ? '<span class="ex-verified">✓ trial passed</span>' : ''}</div>
+<p class="ex-fine">A manifest and passport dry run. v1 does not execute the listing\'s code; it records the checks and gives you a one-hour pass to install.</p>
+${signedIn ? `<form method="post" action="/listings/${esc(l.slug)}/sandbox" class="ex-inline"><button class="ex-btn" type="submit">Run the sandbox</button></form>` : `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to try this listing in the sandbox.</p>`}
+</section>` : '';
+
+ const install = offersOpen ? `<section class="ex-install-sec" id="ex-install" aria-labelledby="ex-install-h">
+<div class="ex-section-head"><h2 id="ex-install-h">Install</h2>${activeInstall ? '<span class="ex-status ex-status-active">installed</span>' : ''}</div>
+${activeInstall ? `<p class="ex-fine">Installed version ${esc(activeInstall.version)} with the passport below. Revoking removes it here and, from v2, at Network too.</p><form method="post" action="/installs/${esc(activeInstall.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke in one tap</button></form>` : ''}
+${!activeInstall && trialPassed ? `<p class="ex-fine">Your sandbox trial passed for version ${esc(l.version)}. The install keeps this passport; it does not widen it.</p><form method="post" action="/listings/${esc(l.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}
+${!activeInstall && !trialPassed && signedIn && !mine ? '<p class="ex-note">Run the sandbox first: a passed trial for this version is required before an install.</p>' : ''}
+${!signedIn ? `<p class="ex-note"><a href="/auth/login?next=${encodeURIComponent(`/listings/${l.slug}`)}">Sign in</a> to install.</p>` : ''}
+</section>` : '';
+
+ const offerForm = offersOpen && signedIn && !mine ? `<section class="ex-offer-form-sec" id="ex-offer-form" aria-labelledby="ex-offer-form-h">
+<div class="ex-section-head"><h2 id="ex-offer-form-h">${l.mode === 'free' ? 'Claim this' : 'Offer a swap'}</h2>${modeBadge(l.mode)}</div>
+<form method="post" action="/listings/${esc(l.slug)}/offers" class="ex-form">
+<label class="ex-field"><span>${l.mode === 'free' ? 'Say why you would like it' : 'What you offer in return'}</span><textarea name="${l.mode === 'free' ? 'body' : 'body'}" rows="3" maxlength="1000" required></textarea></label>
+${l.mode === 'swap' ? `<label class="ex-field"><span>What you are giving</span><textarea name="give" rows="3" maxlength="1000" required></textarea></label>` : ''}
+<button class="ex-btn" type="submit">${l.mode === 'free' ? 'Send the claim' : 'Send the offer'}</button>
+</form>
+<p class="ex-fine">No money changes hands. A swap is between the two people; accepting one closes the listing.</p>
+</section>` : '';
+
+ const offersSection = mine && offers.length ? `<section class="ex-offers-sec" id="ex-offers" aria-labelledby="ex-offers-h">
+<div class="ex-section-head"><h2 id="ex-offers-h">Open ${l.mode === 'free' ? 'claims' : 'offers'}</h2><span class="ex-count">${offers.length}</span></div>
+${offerList(offers, { owner: true })}
+</section>` : '';
+
+ return `<div class="ex-wrap" id="ex-listing" data-listing="${esc(l.slug)}">
+<article class="ex-listing">
+<header class="ex-listing-h">
+<p class="ex-kicker">${kindLabel(l.kind)} · ${modeBadge(l.mode)}${statusBadge(l.status)}${staff ? ' · <span class="ex-staff">staff view</span>' : ''}</p>
+<h1>${esc(l.title)}</h1>
+<p class="ex-lede">${esc(l.summary)}</p>
+<p class="ex-byline">by <span class="ex-creator">${esc(l.creator)}</span> · version <span class="ex-version">${esc(l.version)}</span> · <span class="ex-license">${esc(l.license)}</span>${l.license_url ? ` · <a href="${esc(l.license_url)}" rel="nofollow noopener">the licence</a>` : ''}</p>
+</header>
+
+${notice ? `<p class="ex-note" role="status">${esc(notice)}</p>` : ''}
+${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+
+<section class="ex-about" id="ex-about" aria-labelledby="ex-about-h">
+<h2 id="ex-about-h">About this listing</h2>
+${l.description ? `<div class="ex-prose">${esc(l.description).replace(/\n/g, '<br>')}</div>` : '<p class="ex-muted">The maker has not added a longer description.</p>'}
+${l.mode === 'swap' && l.wants ? `<p class="ex-wants"><b>Wants in return:</b> ${esc(l.wants)}</p>` : ''}
+<dl class="ex-facts">
+<div><dt>Version</dt><dd>${esc(l.version)}</dd></div>
+<div><dt>Licence</dt><dd>${esc(l.license)}</dd></div>
+<div><dt>Mode</dt><dd>${esc(MODE_LABEL[l.mode] || l.mode)}</dd></div>
+<div><dt>Listed</dt><dd>${l.listed_at ? time(l.listed_at) : 'not yet'}</dd></div>
+<div><dt>Revision</dt><dd>${Number(l.revision) || 1}</dd></div>
+</dl>
+</section>
+
+${passport(l.passport)}
+${testsBlock(tests)}
+${sandbox}
+${install}
+${offersSection}
+${offerForm}
+${ownerPanel}
+
+<section class="ex-manifest" id="ex-manifest" aria-labelledby="ex-manifest-h">
+<div class="ex-section-head"><h2 id="ex-manifest-h">Manifest</h2></div>
+<dl class="ex-facts"><div><dt>Name</dt><dd>${esc(l.manifest ? l.manifest.name : '')}</dd></div><div><dt>Entry</dt><dd><code>${esc(l.manifest ? l.manifest.entry : '')}</code></dd></div><div><dt>Runtime</dt><dd>${esc(l.manifest && l.manifest.runtime ? l.manifest.runtime : 'not stated')}</dd></div></dl>
+<p class="ex-fine">Public metadata only. A manifest that looks like a credential is refused when the listing is submitted.</p>
+</section>
+
+<footer class="ex-listing-foot"><p class="ex-fine">${esc(SAFETY_NOTE)}</p></footer>
+</article>
+</div>`;
+}
+
+const SAFETY_NOTE = 'v1 is free and swap only: nobody should send money. Read the passport before installing, and revoke anything you no longer use.';
+
+const textarea = (name, value, rows = 4, max = 4000) => `<textarea name="${name}" rows="${rows}" maxlength="${max}">${esc(value || '')}</textarea>`;
+
+function submitForm({ values = {}, mine = [], problem = null } = {}) {
+ const v = {
+ title: '', kind: 'template', mode: 'free', summary: '', description: '', wants: '', license: 'CC0-1.0',
+ license_url: '', version: '1.0.0', manifest_name: '', manifest_entry: '', manifest_runtime: '',
+ passport_reads: '', passport_writes: '', passport_contacts: '', ...values,
+ };
+ return `<div class="ex-wrap" id="ex-submit">
+<header class="ex-page-h"><p class="ex-kicker">List something</p><h1>Trade what you make</h1>
+<p class="ex-lede">A listing is a draft first. Exchange runs the conformance tests when you submit it; when they pass you can list it publicly. v1 is free or swap only: there is no price and the passport may not declare a spend.</p></header>
+${problem ? `<p class="ex-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+<form id="ex-listing-form" class="ex-form ex-card" method="post" action="/submit">
+<label class="ex-field"><span>Title</span><input name="title" type="text" maxlength="120" required value="${esc(v.title)}" placeholder="A starter website template"></label>
+<div class="ex-field-row">
+<label class="ex-field"><span>Kind</span><select name="kind">${Object.entries(KIND_LABEL).map(([k, label]) => `<option value="${esc(k)}"${v.kind === k ? ' selected' : ''}>${esc(label)}</option>`).join('')}</select></label>
+<label class="ex-field"><span>Mode</span><select name="mode"><option value="free"${v.mode === 'free' ? ' selected' : ''}>Free</option><option value="swap"${v.mode === 'swap' ? ' selected' : ''}>Swap</option></select></label>
+</div>
+<label class="ex-field"><span>Summary</span><input name="summary" type="text" maxlength="600" required value="${esc(v.summary)}" placeholder="What it is, in one line."></label>
+<label class="ex-field"><span>Description</span>${textarea('description', v.description, 6, 4000)}</label>
+<label class="ex-field"><span>What a swap wants in return (swap only)</span><input name="wants" type="text" maxlength="300" value="${esc(v.wants)}" placeholder="A type kit, a game map, a skill swap…"></label>
+<div class="ex-field-row">
+<label class="ex-field"><span>Licence (SPDX)</span><input name="license" type="text" maxlength="64" required value="${esc(v.license)}" placeholder="CC0-1.0"></label>
+<label class="ex-field"><span>Licence URL (optional)</span><input name="license_url" type="url" maxlength="300" value="${esc(v.license_url)}" placeholder="https://…"></label>
+<label class="ex-field"><span>Version</span><input name="version" type="text" maxlength="32" required value="${esc(v.version)}" placeholder="1.0.0"></label>
+</div>
+<fieldset class="ex-fieldset"><legend>Permission passport</legend>
+<p class="ex-fine">One permission per line. Reads and writes are lower-case names such as <code>search.read</code> or <code>space.files.write</code>; contacts are https origins. Spending is fixed to nothing in v1.</p>
+<label class="ex-field"><span>Can read</span>${textarea('passport_reads', v.passport_reads, 3, 1000)}</label>
+<label class="ex-field"><span>Can write</span>${textarea('passport_writes', v.passport_writes, 3, 1000)}</label>
+<label class="ex-field"><span>Can contact</span>${textarea('passport_contacts', v.passport_contacts, 3, 1000)}</label>
+<label class="ex-field"><span>Can spend</span><input type="text" value="Nothing — v1 has no money" disabled></label>
+</fieldset>
+<fieldset class="ex-fieldset"><legend>Manifest</legend>
+<p class="ex-fine">Public metadata only: the name, the entry point and the runtime. Never put a secret, token or key in a manifest; it is refused.</p>
+<label class="ex-field"><span>Manifest name</span><input name="manifest_name" type="text" maxlength="80" required value="${esc(v.manifest_name)}" placeholder="Starter site"></label>
+<label class="ex-field"><span>Entry point</span><input name="manifest_entry" type="text" maxlength="200" required value="${esc(v.manifest_entry)}" placeholder="templates/starter/index.html"></label>
+<label class="ex-field"><span>Runtime</span><input name="manifest_runtime" type="text" maxlength="80" value="${esc(v.manifest_runtime)}" placeholder="static"></label>
+</fieldset>
+<div class="ex-actions"><button class="ex-btn" type="submit">Save the draft</button><a class="ex-btn ex-btn-quiet" href="/listings">Cancel</a></div>
+<p class="ex-fine">By listing you confirm you have the rights to what you share and that it follows the <a href="/safety">safety rules</a>.</p>
+</form>
+${mine.length ? `<section class="ex-mine" id="ex-mine" aria-labelledby="ex-mine-h"><div class="ex-section-head"><h2 id="ex-mine-h">Your listings</h2><span class="ex-count">${mine.length}</span></div><ul class="ex-mine-list">${mine.map((l) => `<li class="ex-mine-item"><a href="/listings/${esc(l.slug)}">${esc(l.title)}</a> ${statusBadge(l.status)} ${modeBadge(l.mode)} <span class="ex-muted">v${esc(l.version)}</span></li>`).join('')}</ul></section>` : ''}
+</div>`;
+}
+
+function sandboxPage({ trial, listing } = {}) {
+ const t = trial;
+ return `<div class="ex-wrap" id="ex-sandbox-trial" data-trial="${esc(t.id)}">
+<header class="ex-page-h"><p class="ex-kicker">Sandbox trial</p><h1>${t.status === 'passed' ? 'The dry run passed' : 'The dry run did not pass'}</h1>
+<p class="ex-lede">${esc(listing ? listing.title : '')} · version ${esc(t.version)}. ${t.status === 'passed' ? 'You can install this version with its passport until the trial expires.' : 'Nothing was installed. Fix the failed checks and try again.'}</p></header>
+<p class="ex-note ${t.status === 'passed' ? '' : 'is-bad'}" role="status">Status: <b>${esc(t.status)}</b> · expires ${time(t.expires_at)}</p>
+<ul class="ex-checks">${(t.checks || []).map((c) => `<li class="ex-check ex-check-${esc(c.status)}"><b>${esc(c.name)}</b> <span>${esc(c.status)}</span><span class="ex-check-detail">${esc(c.detail || '')}</span></li>`).join('')}</ul>
+${passport(t.passport)}
+<div class="ex-actions">
+${t.status === 'passed' && !t.consumed_at && listing ? `<form method="post" action="/listings/${esc(listing.slug)}/install" class="ex-inline"><button class="ex-btn" type="submit">Install with these grants</button></form>` : ''}
+${t.consumed_at ? '<p class="ex-fine">This trial was used by an install. Run a fresh one to install again.</p>' : ''}
+<a class="ex-btn ex-btn-quiet" href="/listings/${esc(listing ? listing.slug : '')}">Back to the listing</a>
+</div>
+</div>`;
+}
+
+function installsPage({ installs = [] } = {}) {
+ const active = installs.filter((i) => i.status === 'active');
+ const revoked = installs.filter((i) => i.status !== 'active');
+ return `<div class="ex-wrap" id="ex-installs">
+<header class="ex-page-h"><p class="ex-kicker">Your installations</p><h1>What you have installed</h1>
+<p class="ex-lede">Each install keeps the exact passport it was made with. Revoke in one tap; installing again needs a fresh sandbox trial, so the passport is re-checked.</p></header>
+<section class="ex-install-active" id="ex-installs-active" aria-labelledby="ex-installs-active-h">
+<div class="ex-section-head"><h2 id="ex-installs-active-h">Active</h2><span class="ex-count">${active.length}</span></div>
+${active.length ? `<ul class="ex-install-list">${active.map((i) => `<li class="ex-install" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">${esc(KIND_LABEL[i.listing.kind] || i.listing.kind)} · v${esc(i.version)} · installed ${time(i.installed_at)}</span>${passport(i.passport, { id: null })}</div><form method="post" action="/installs/${esc(i.id)}/revoke" class="ex-inline"><button class="ex-btn ex-btn-quiet" type="submit">Revoke</button></form></li>`).join('')}</ul>` : empty('Nothing installed yet. Try a listing in the sandbox, then install it with its passport.', '/listings', 'Browse listings')}
+</section>
+<section class="ex-install-revoked" id="ex-installs-revoked" aria-labelledby="ex-installs-revoked-h">
+<div class="ex-section-head"><h2 id="ex-installs-revoked-h">Revoked</h2><span class="ex-count">${revoked.length}</span></div>
+${revoked.length ? `<ul class="ex-install-list">${revoked.map((i) => `<li class="ex-install is-revoked" data-install="${esc(i.id)}"><div class="ex-install-main"><a class="ex-install-title" href="/listings/${esc(i.listing.slug)}">${esc(i.listing.title)}</a><span class="ex-install-meta">v${esc(i.version)} · revoked ${time(i.revoked_at)}</span></div><span class="ex-status ex-status-revoked">revoked</span></li>`).join('')}</ul>` : '<p class="ex-muted">No installation has been revoked.</p>'}
+</section>
+</div>`;
+}
+
+function offersPage({ sent = [], received = [] } = {}) {
+ return `<div class="ex-wrap" id="ex-offers">
+<header class="ex-page-h"><p class="ex-kicker">Free claims and swap offers</p><h1>Your offers</h1>
+<p class="ex-lede">A free listing takes claims, a swap listing takes offers. Accepting one closes the listing as given or swapped; nothing is paid through Exchange.</p></header>
+<section id="ex-offers-received" aria-labelledby="ex-offers-received-h">
+<div class="ex-section-head"><h2 id="ex-offers-received-h">Received</h2><span class="ex-count">${received.length}</span></div>
+${received.length ? `<ul class="ex-offer-list">${received.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p>${o.give ? `<p class="ex-offer-give"><b>In return:</b> ${esc(o.give)}</p>` : ''}<span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<div class="ex-actions"><form method="post" action="/offers/${esc(o.id)}/accept" class="ex-inline"><button class="ex-btn ex-btn-small" type="submit">Accept</button></form><form method="post" action="/offers/${esc(o.id)}/decline" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Decline</button></form></div>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">Nobody has claimed or offered on your listings yet.</p>'}
+</section>
+<section id="ex-offers-sent" aria-labelledby="ex-offers-sent-h">
+<div class="ex-section-head"><h2 id="ex-offers-sent-h">Sent</h2><span class="ex-count">${sent.length}</span></div>
+${sent.length ? `<ul class="ex-offer-list">${sent.map((o) => `<li class="ex-offer" data-offer="${esc(o.id)}"><div class="ex-offer-head"><a href="/listings/${esc(o.listing.slug)}">${esc(o.listing.title)}</a><span class="ex-offer-kind">${o.kind === 'claim' ? 'claim' : 'swap offer'}</span><span class="ex-offer-when">${time(o.created_at)}</span></div><p class="ex-offer-body">${esc(o.body)}</p><span class="ex-status ex-status-${esc(o.status)}">${esc(o.status)}</span>${o.status === 'open' ? `<form method="post" action="/offers/${esc(o.id)}/withdraw" class="ex-inline"><button class="ex-btn ex-btn-small ex-btn-quiet" type="submit">Withdraw</button></form>` : ''}</li>`).join('')}</ul>` : '<p class="ex-muted">You have not claimed or offered on a listing yet.</p>'}
+</section>
+</div>`;
+}
+
+function safetyPage() {
+ return `<div class="ex-wrap" id="ex-safety">
+<header class="ex-page-h"><p class="ex-kicker">Safety</p><h1>Free and swap, safely</h1>
+<p class="ex-lede">OpenVibe.Exchange v1 has no money in it. That removes most of the scams; it does not remove all of them.</p></header>
+<p class="ex-note">${esc(SAFETY_NOTE)}</p>
+<section><h2>No money, anywhere</h2><ul class="ex-prose-list">
+<li>There is no price, no checkout and no Billing in v1. A listing is free or a swap.</li>
+<li>Nobody should send money, a gift card, crypto or a deposit for anything here. A listing, offer or passport that asks for payment is refused when it is submitted.</li>
+<li>A passport's <b>spend</b> is always nothing in v1. If a listing ever shows a spend in its passport, do not install it; report it to staff.</li>
+</ul></section>
+<section><h2>Read the passport</h2><p class="ex-prose">The passport says exactly what the listing can read, write and contact. The sandbox checks it, the install snapshots it, and a later edit to the listing cannot widen an install you already made. Revoke anything you no longer use.</p></section>
+<section><h2>What may not be listed</h2><p class="ex-prose">The fixed prohibited-items list: weapons and ammunition, illegal drugs, malware and stolen credentials or access, sexual services or content, forged or counterfeit documents, and live animals. Staff can hide anything else that breaks the rules.</p></section>
+<section><h2>For a local swap</h2><ul class="ex-prose-list">
+<li>Meet in a public place, in daylight if you can, and take someone with you.</li>
+<li>Check what you are swapping before you hand anything over; Exchange does not inspect or hold it.</li>
+<li>Keep the arrangement on the listing where you can, and keep a copy of what you agreed.</li>
+<li>Never share a password, a token or a private key to prove something works. A listing's manifest is public metadata and must never carry one.</li>
+</ul></section>
+<section><h2>What Exchange does not do</h2><p class="ex-prose">Exchange does not execute a listing in v1 (the sandbox is a dry run), does not hold a swap in escrow, and does not guarantee that a maker owns what they list. The licence on the listing is the maker's promise; read it before you install.</p></section>
+</div>`;
+}
+
+function developersPage() {
+ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
+ return `<div class="ex-wrap" id="ex-developers">
+<header class="ex-page-h"><p class="ex-kicker">Developers</p><h1>The Exchange API</h1>
+<p class="ex-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>exchange_at</code> session cookie or a Network user token as a Bearer.</p></header>
+<section><h2>Read</h2><dl class="ex-eps">
+${ep('GET', '/api/v1/listings?limit=&cursor=&mode=&kind=&q=', 'Public listings, newest first, cursor-paged (at most 50).')}
+${ep('GET', '/api/v1/listings/:slug', 'One listing with its passport, manifest, licence, tests and counters. A draft/hidden/closed one is 404 unless it is yours or you are staff.')}
+${ep('GET', '/api/v1/listings/:slug/passport', 'The exact passport and manifest an installer accepts.')}
+${ep('GET', '/api/v1/listings/:slug/tests', 'The conformance tests for the version the listing names.')}
+</dl></section>
+<section><h2>Write (signed in, same-origin)</h2><dl class="ex-eps">
+${ep('POST', '/api/v1/listings', '<code>{ title, kind, mode, summary, description?, wants?, license, license_url?, version, manifest: { name, entry, runtime? }, passport: { reads, writes, spends, contacts } }</code>. Saves a draft; <code>spends</code> must be empty.')}
+${ep('POST', '/api/v1/listings/:slug/list', 'Publish a draft after every conformance test for its version passed.')}
+${ep('POST', '/api/v1/listings/:slug/close', '<code>{ reason: "withdrawn"|"given"|"swapped" }</code>, the owner. Closes it and tombstones its Search document.')}
+${ep('POST', '/api/v1/listings/:slug/sandbox', 'Run a manifest dry-run trial. A passed trial is valid for the configured window.')}
+${ep('GET', '/api/v1/sandbox/:id', 'One trial of yours and its checks.')}
+${ep('POST', '/api/v1/listings/:slug/install', 'Install with the passport snapshot. 409 <code>exchange.sandbox_required</code> without a passed trial for this version.')}
+${ep('GET', '/api/v1/installs', 'Your installations, active and revoked.')}
+${ep('POST', '/api/v1/installs/:id/revoke', 'Revoke in one tap. Idempotent.')}
+${ep('POST', '/api/v1/listings/:slug/offers', '<code>{ body, give? }</code>: a free claim, or a swap offer with what you give. No money.')}
+${ep('GET', '/api/v1/offers', 'The offers you sent and received.')}
+${ep('POST', '/api/v1/offers/:id/accept|decline|withdraw', 'The owner accepts/declines; the sender withdraws. Accepting closes the listing as given or swapped.')}
+${ep('GET', '/api/v1/me/listings', 'Every listing of yours, all statuses.')}
+</dl></section>
+<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="ex-eps">
+${ep('POST', '/api/v1/listings/:slug/hide', 'Hide a listing and tombstone its Search document.')}
+${ep('GET', '/api/v1/listings/:slug/offers', 'The open claims/offers on a listing.')}
+</dl></section>
+<section id="ex-search"><h2>Search</h2><p class="ex-fine">Each public listing is one <code>search.index-document@1</code> (owner <code>exchange</code>, type <code>listing</code>, canonical page <code>/listings/<slug></code>) queued in <code>exchange_outbox</code>; closing or hiding it queues a tombstone. The outbox waits for Network's <code>search.document.write</code> grant; the relay is not part of v1.</p></section>
+<section id="ex-grants"><h2>Grants v1 does not have yet</h2><p class="ex-fine">The plan enforces a passport with Network grants and executes a listing in a Run sandbox. v1 does neither: an install is an Exchange record and the sandbox is a dry run. The service will ask for <code>network.grants.write</code> and <code>network.grants.revoke</code> before an install/revoke can reach Network, <code>run.sandbox.execute</code> before a trial can run code, and <code>search.document.write</code> before the outbox can reach Search. Account export and deletion need <code>network.account.export.contribute</code> and <code>network.account.deletion.confirm</code>; the two Events subscriptions need <code>events.subscription.manage</code>.</p></section>
+</div>`;
+}
+
+function signIn({ title, text, next }) {
+ return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="ex-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
+}
+
+function notFound() {
+ return `<div class="ex-wrap ex-page-pad ex-narrow"><div class="ex-empty" id="ex-not-found"><p class="ex-kicker">404</p><h1>This listing is not here</h1><p>It may have been closed, hidden by staff, or the address was never a listing.</p><a class="ex-btn" href="/listings">Browse the listings</a></div></div>`;
+}
+
+module.exports = {
+ home, listingsPage, listingPage, submitForm, sandboxPage, installsPage, offersPage, safetyPage, developersPage,
+ signIn, notFound, passport, testsBlock, card, empty, kindLabel, modeBadge, statusBadge,
+ KIND_LABEL, MODE_LABEL, STATUS_LABEL, CLOSED_LABEL, PASSPORT_LABEL, SAFETY_NOTE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * ADR-033: Exchange's part of an account export (listings, installations, sandbox trials, offers) and of a deletion,
+ * applied through /internal/events with a stand-in Network. A deletion anonymises and closes a public listing with
+ * a Search tombstone, deletes a draft outright, and erases the person's installs, trials and offers; another
+ * person's listing stays. A redelivery changes nothing; a bad signature and a proxied request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+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_exchange', 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: 'exchange', clientSecret: 'exchange-secret' });
+ const t = await boot({ env: { EXCHANGE_EVENTS_SECRET: SECRET }, accountSend: sender });
+ const alice = { id: 101, subject: A, username: 'alice', display_name: 'alice', role: 'user' };
+ const bob = { id: 102, subject: B, username: 'bob', display_name: 'bob', role: 'user' };
+ try {
+ const aListed = await fixtures.listed(t, alice, { title: 'Alice\'s listed theme', kind: 'theme' });
+ const aDraft = await fixtures.create(t, alice, { title: 'Alice\'s draft theme', kind: 'theme' });
+ const bListed = await fixtures.listed(t, bob, { title: 'Bob\'s listed theme', kind: 'theme' });
+ const sb = await fixtures.sandbox(t, alice, bListed.slug);
+ const inst = await fixtures.install(t, alice, bListed.slug);
+ const claim = await t.get(`/api/v1/listings/${bListed.slug}/offers`, { as: alice, json: { body: 'I would like Bob\'s theme.' } });
+ assert.strictEqual(claim.status, 201);
+
+ await check('an export carries Alice\'s listings, trial, install and offer, and not Bob\'s', async () => {
+ const body = bodyOf(exportEvent);
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_exchange');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['installs.json', 'listings.json', 'offers.json', 'sandbox-trials.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('Alice\'s listed theme'));
+ assert.ok(all.includes('Alice\'s draft theme'));
+ assert.ok(!all.includes('Bob\'s listed theme'), 'nobody else\'s listing');
+ assert.strictEqual(part.body.files.find((f) => f.name === 'installs.json').content[0].id, inst.id);
+ assert.strictEqual(part.body.files.find((f) => f.name === 'sandbox-trials.json').content[0].id, sb.trial.id);
+ assert.match(all, /I would like Bob/);
+ });
+
+ await check('a deletion closes the listed listing with a tombstone, deletes the draft and Alice\'s rows; Bob stays', async () => {
+ const body = bodyOf(deleteEvent);
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ const db = t.ctx.s.db;
+ const listed = await db.maybe('SELECT owner, status, closed_reason, title, summary, creator FROM exchange_listings WHERE id = $1', [aListed.id]);
+ assert.strictEqual(listed.owner, null);
+ assert.strictEqual(listed.status, 'closed');
+ assert.strictEqual(listed.closed_reason, 'withdrawn');
+ assert.strictEqual(listed.title, 'A deleted listing');
+ assert.strictEqual(listed.summary, "Removed with its maker's account.");
+ assert.strictEqual(listed.creator, 'a deleted maker');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_listings WHERE id = $1', [aDraft.id])), 0);
+ const bobRow = await db.maybe('SELECT owner, status FROM exchange_listings WHERE id = $1', [bListed.id]);
+ assert.strictEqual(bobRow.owner, B);
+ assert.strictEqual(bobRow.status, 'listed');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_installs WHERE id = $1', [inst.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE id = $1', [sb.trial.id])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM exchange_offers WHERE id = $1', [claim.json().id])), 0);
+ const tomb = await db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id]);
+ assert.ok(tomb, 'a tombstone was queued');
+ assert.strictEqual(tomb.payload.deleted, true);
+ 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.exchange_listings_drafts >= 1);
+ assert.ok(confirmation.body.retained.exchange_listings_closed >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing', async () => {
+ const body = bodyOf(deleteEvent);
+ const res = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [aListed.id])), 1, 'no second tombstone');
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const body = bodyOf(exportEvent);
+ const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(body, SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * The JSON API: a draft is created and is not public; publishing it needs the conformance tests; the public list
+ * pages with an opaque cursor and filters; detail/passport/tests shapes; validation refusals; owner and staff
+ * visibility; close and hide. There is no price in any shape.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ try {
+ let draft = null;
+ await check('POST /listings saves a draft with its passport and tests; it is not public yet', async () => {
+ draft = await fixtures.create(t, alice);
+ assert.match(draft.id, /^exl_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(draft.slug, 'starter-site-template');
+ assert.strictEqual(draft.status, 'draft');
+ assert.strictEqual(draft.mode, 'free');
+ assert.deepStrictEqual(draft.passport.spends, []);
+ assert.strictEqual(draft.tests.verified, true);
+ assert.strictEqual(draft.tests.count, 4);
+ assert.ok(!('price' in draft));
+ const pub = await t.get('/api/v1/listings');
+ assert.strictEqual(pub.json().count, 0);
+ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
+ });
+
+ await check('the owner and staff see a draft; another person does not', async () => {
+ const owner = await t.get(`/api/v1/listings/${draft.slug}`, { as: alice });
+ assert.strictEqual(owner.status, 200);
+ assert.strictEqual(owner.json().status, 'draft');
+ assert.strictEqual(owner.json().owner, alice.subject);
+ assert.strictEqual(owner.json().mine, true);
+ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`, { as: bob })).status, 404);
+ const staff = await t.get(`/api/v1/listings/${draft.slug}`, { as: mod });
+ assert.strictEqual(staff.status, 200);
+ assert.strictEqual(staff.json().staff, true);
+ });
+
+ await check('publishing makes it public with the passport, licence and versioned tests', async () => {
+ const pub = await t.get(`/api/v1/listings/${draft.slug}/list`, { as: alice, method: 'POST' });
+ assert.strictEqual(pub.status, 200, pub.text);
+ assert.strictEqual(pub.json().status, 'listed');
+ const list = await t.get('/api/v1/listings?mode=free&kind=template');
+ assert.strictEqual(list.json().count, 1);
+ assert.strictEqual(list.headers.get('cache-control'), 'public, max-age=60');
+ const one = list.json().listings[0];
+ assert.strictEqual(one.slug, draft.slug);
+ assert.strictEqual(one.tests.verified, true);
+ assert.deepStrictEqual(one.passport.reads, ['search.read']);
+ const detail = await t.get(`/api/v1/listings/${draft.slug}`);
+ assert.strictEqual(detail.status, 200);
+ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(detail.json().license, 'CC0-1.0');
+ assert.strictEqual(detail.json().manifest.entry, 'templates/starter/index.html');
+ assert.strictEqual(detail.json().tests.rows.length, 4);
+ const passport = await t.get(`/api/v1/listings/${draft.slug}/passport`);
+ assert.strictEqual(passport.status, 200);
+ assert.deepStrictEqual(passport.json().passport.writes, ['space.files.write']);
+ assert.deepStrictEqual(passport.json().passport.spends, []);
+ const tests = await t.get(`/api/v1/listings/${draft.slug}/tests`);
+ assert.strictEqual(tests.json().verified, true);
+ assert.deepStrictEqual(tests.json().tests.map((x) => x.name).sort(), ['licence', 'manifest', 'no_money', 'passport']);
+ });
+
+ await check('the list pages with an opaque cursor and filters, and refuses a garbage cursor', async () => {
+ const two = await fixtures.listed(t, alice, { title: 'A calm theme kit', kind: 'theme', mode: 'swap', wants: 'A type kit' });
+ const three = await fixtures.listed(t, alice, { title: 'A surf map', kind: 'game_asset' });
+ const one = await t.get('/api/v1/listings?limit=2');
+ assert.strictEqual(one.json().count, 2);
+ assert.ok(one.json().next_cursor);
+ const next = await t.get(`/api/v1/listings?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);
+ assert.strictEqual(next.status, 200);
+ assert.strictEqual(next.json().count, 1);
+ assert.strictEqual(next.json().next_cursor, null);
+ assert.strictEqual((await t.get('/api/v1/listings?cursor=garbage')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/listings?kind=nope')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/listings?mode=paid')).status, 400);
+ const swaps = await t.get('/api/v1/listings?mode=swap');
+ assert.strictEqual(swaps.json().count, 1);
+ assert.strictEqual(swaps.json().listings[0].slug, two.slug);
+ const q = await t.get('/api/v1/listings?q=surf');
+ assert.strictEqual(q.json().count, 1);
+ assert.strictEqual(q.json().listings[0].slug, three.slug);
+ });
+
+ await check('a bad listing is refused with a code, in the API and the form alike', async () => {
+ const post = (body) => t.get('/api/v1/listings', { as: alice, json: body });
+ const base = fixtures.body();
+ assert.strictEqual((await post({ ...base, title: '' })).status, 400);
+ assert.strictEqual((await post({ ...base, spends: [] })).status, 400);
+ const money = await post({ ...base, summary: 'Yours for $20, cash only.' });
+ assert.strictEqual(money.status, 400);
+ assert.strictEqual(money.json().code, 'exchange.prohibited');
+ const spend = await post({ ...base, passport: { ...base.passport, spends: ['billing.spend'] } });
+ assert.strictEqual(spend.status, 400);
+ assert.strictEqual(spend.json().code, 'exchange.no_money');
+ const secret = await post({ ...base, manifest: { ...base.manifest, token: 'ghp_123' } });
+ assert.strictEqual(secret.status, 400);
+ const secretValue = await post({ ...base, manifest: { ...base.manifest, entry: 'api_key=sk-live-123' } });
+ assert.strictEqual(secretValue.status, 400);
+ assert.strictEqual(secretValue.json().code, 'exchange.manifest_secret');
+ const freeWants = await post({ ...base, mode: 'free', wants: 'A type kit' });
+ assert.strictEqual(freeWants.status, 400);
+ const swapNoWants = await post({ ...base, mode: 'swap', wants: '' });
+ assert.strictEqual(swapNoWants.status, 400);
+ const badVersion = await post({ ...base, version: 'latest' });
+ assert.strictEqual(badVersion.status, 400);
+ });
+
+ await check('a malformed body and an unknown API route are problem+json', async () => {
+ const bad = await t.get('/api/v1/listings', { as: alice, body: '{', headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(bad.status, 400);
+ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(bad.json().code, 'request.malformed_json');
+ const nope = await t.get('/api/v1/nope');
+ assert.strictEqual(nope.status, 404);
+ assert.strictEqual(nope.json().code, 'route.not_found');
+ });
+
+ await check('/me/listings has every status of yours', async () => {
+ const draft2 = await fixtures.create(t, alice, { title: 'A second draft' });
+ const mine = await t.get('/api/v1/me/listings', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.ok(mine.json().listings.some((x) => x.slug === draft2.slug && x.status === 'draft'));
+ assert.strictEqual((await t.get('/api/v1/me/listings')).status, 401);
+ });
+
+ await check('close is the owner\'s, removes it from the public list, and is idempotent', async () => {
+ const close = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: bob, method: 'POST' });
+ assert.strictEqual(close.status, 404);
+ const ok = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
+ assert.strictEqual(ok.status, 200);
+ assert.strictEqual(ok.json().status, 'closed');
+ assert.strictEqual(ok.json().closed_reason, 'withdrawn');
+ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}`)).status, 404);
+ const again = await t.get(`/api/v1/listings/${draft.slug}/close`, { as: alice, method: 'POST', json: {} });
+ assert.strictEqual(again.status, 200);
+ const pub = await t.get('/api/v1/listings?limit=50');
+ assert.ok(!pub.json().listings.some((x) => x.slug === draft.slug));
+ });
+
+ await check('staff can hide a listing; the owner still sees it', async () => {
+ const l = await fixtures.listed(t, alice, { title: 'A hideable template' });
+ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}/hide`, { as: alice, method: 'POST' })).status, 403);
+ const hide = await t.get(`/api/v1/listings/${l.slug}/hide`, { as: mod, method: 'POST' });
+ assert.strictEqual(hide.status, 200);
+ assert.strictEqual(hide.json().status, 'hidden');
+ assert.strictEqual((await t.get(`/api/v1/listings/${l.slug}`)).status, 404);
+ const owner = await t.get(`/api/v1/listings/${l.slug}`, { as: alice });
+ assert.strictEqual(owner.status, 200);
+ assert.strictEqual(owner.json().status, 'hidden');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.EXCHANGE_RATE_LIMIT_PER_MIN = process.env.EXCHANGE_RATE_LIMIT_PER_MIN || '100000';
+process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN = process.env.EXCHANGE_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.EXCHANGE_LIMITS_MINUTE = process.env.EXCHANGE_LIMITS_MINUTE || '100000';
+process.env.EXCHANGE_LIMITS_HOUR = process.env.EXCHANGE_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Exchange on a temp database with a mock Network (test/helpers/mocks.js), captures log lines, and
+ * returns a small HTTP client. Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
+ * t.signIn(user) → cookie header value (exchange_at = a Network user token, as after /auth/callback)
+ * 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();
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.exchange', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'exchange', OV_OAUTH_CLIENT_SECRET: 'exchange-secret', COOKIE_SECURE: 'false',
+ EXCHANGE_DRILL: '1',
+ EXCHANGE_EVENTS_URL: network.url,
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
+
+ const config = configLib.load(env);
+ const { createStore } = require('../../server/db');
+ const testdb = await require('./db').testDb();
+ const store = createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `exchange_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ 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;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * EXCHANGE_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.EXCHANGE_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'exchange', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/** Shared fixtures: a valid CC0 template and helpers to create / publish it through the public API. */
+
+const BODY = Object.freeze({
+ title: 'Starter site template',
+ kind: 'template',
+ mode: 'free',
+ summary: 'A licensed, static starter site: one page, no tracking.',
+ description: 'Copy it into a Space, change the words, publish.',
+ license: 'CC0-1.0',
+ license_url: 'https://creativecommons.org/publicdomain/zero/1.0/',
+ version: '1.0.0',
+ manifest: { name: 'Starter site', entry: 'templates/starter/index.html', runtime: 'static' },
+ passport: { reads: ['search.read'], writes: ['space.files.write'], spends: [], contacts: ['https://openvibe.space'] },
+});
+
+function body(overrides = {}) {
+ return {
+ ...BODY,
+ ...overrides,
+ manifest: { ...BODY.manifest, ...(overrides.manifest || {}) },
+ passport: { ...BODY.passport, ...(overrides.passport || {}) },
+ };
+}
+
+async function create(t, user, overrides = {}) {
+ const r = await t.get('/api/v1/listings', { as: user, json: body(overrides) });
+ if (r.status !== 201) throw new Error(`create listing ${r.status}: ${r.text}`);
+ return r.json();
+}
+
+async function listed(t, user, overrides = {}) {
+ const l = await create(t, user, overrides);
+ const r = await t.get(`/api/v1/listings/${l.slug}/list`, { as: user, method: 'POST' });
+ if (r.status !== 200) throw new Error(`publish listing ${r.status}: ${r.text}`);
+ return r.json();
+}
+
+async function sandbox(t, user, slug) {
+ const r = await t.get(`/api/v1/listings/${slug}/sandbox`, { as: user, method: 'POST' });
+ if (r.status !== 201) throw new Error(`sandbox ${r.status}: ${r.text}`);
+ return r.json();
+}
+
+async function install(t, user, slug) {
+ const r = await t.get(`/api/v1/listings/${slug}/install`, { as: user, method: 'POST' });
+ if (r.status !== 201 && r.status !== 200) throw new Error(`install ${r.status}: ${r.text}`);
+ return r.json();
+}
+
+module.exports = { BODY, body, create, listed, sandbox, install };
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 `exchange`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Exchange, 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 !== 'exchange' || body.client_secret !== 'exchange-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:exchange', 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,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+/**
+ * Sandbox, install and revoke: a public listing can be tried, a passed trial is required for the install, the
+ * install keeps the passport snapshot, a revoke is one tap and idempotent, and installing again needs a fresh
+ * trial. A draft cannot be tried; another person cannot read or revoke your install.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ try {
+ const draft = await fixtures.create(t, alice);
+ const listing = await fixtures.listed(t, alice, { title: 'A sandboxable template' });
+ let trial = null;
+ let installed = null;
+
+ await check('a draft cannot be tried; a listed one gets a passed manifest dry run', async () => {
+ assert.strictEqual((await t.get(`/api/v1/listings/${draft.slug}/sandbox`, { as: bob, method: 'POST' })).status, 404);
+ const out = await fixtures.sandbox(t, bob, listing.slug);
+ trial = out.trial;
+ assert.match(trial.id, /^exs_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(trial.status, 'passed');
+ assert.strictEqual(trial.checks.length, 4);
+ assert.ok(trial.checks.some((c) => c.name === 'execution' && /dry run/i.test(c.detail)));
+ assert.strictEqual(trial.passport.spends.length, 0);
+ const page = await t.get(`/sandbox/${trial.id}`, { as: bob });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="ex-sandbox-trial"'));
+ assert.ok(page.text.includes('id="ex-passport"'));
+ assert.strictEqual((await t.get(`/sandbox/${trial.id}`, { as: carol })).status, 404);
+ });
+
+ await check('an install is refused until a trial passed, then keeps the passport snapshot', async () => {
+ const noTrial = await fixtures.listed(t, alice, { title: 'A no-trial template' });
+ const r = await t.get(`/api/v1/listings/${noTrial.slug}/install`, { as: carol, method: 'POST' });
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json().code, 'exchange.sandbox_required');
+ const out = await fixtures.install(t, bob, listing.slug);
+ installed = out;
+ assert.match(installed.id, /^exi_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(installed.status, 'active');
+ assert.deepStrictEqual(installed.passport.reads, ['search.read']);
+ assert.strictEqual(installed.listing.slug, listing.slug);
+ const list = await t.get('/api/v1/installs', { as: bob });
+ assert.strictEqual(list.json().count, 1);
+ assert.strictEqual(list.json().installs[0].id, installed.id);
+ const again = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().id, installed.id);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
+ });
+
+ await check('another person cannot revoke your install; a revoke is one tap and idempotent', async () => {
+ assert.strictEqual((await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: carol, method: 'POST' })).status, 404);
+ const r = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().status, 'revoked');
+ assert.ok(r.json().revoked_at);
+ const again = await t.get(`/api/v1/installs/${installed.id}/revoke`, { as: bob, method: 'POST' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().status, 'revoked');
+ const list = await t.get('/api/v1/installs', { as: bob });
+ assert.strictEqual(list.json().installs[0].status, 'revoked');
+ assert.strictEqual((await t.get('/api/v1/installs')).status, 401);
+ });
+
+ await check('installing again needs a fresh trial; the first one was consumed', async () => {
+ const r = await t.get(`/api/v1/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json().code, 'exchange.sandbox_required');
+ const fresh = await fixtures.sandbox(t, bob, listing.slug);
+ assert.notStrictEqual(fresh.trial.id, trial.id);
+ const reinstalled = await fixtures.install(t, bob, listing.slug);
+ assert.strictEqual(reinstalled.status, 'active');
+ assert.notStrictEqual(reinstalled.id, installed.id);
+ const consumed = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_sandbox_trials WHERE consumed_at IS NOT NULL');
+ assert.strictEqual(Number(consumed), 2);
+ });
+
+ await check('an expired trial cannot install', async () => {
+ const l = await fixtures.listed(t, alice, { title: 'An expiring template' });
+ const out = await fixtures.sandbox(t, carol, l.slug);
+ await t.ctx.s.db.exec("UPDATE exchange_sandbox_trials SET created_at = now() - interval '2 hours', expires_at = now() - interval '1 minute' WHERE id = $1", [out.trial.id]);
+ const r = await t.get(`/api/v1/listings/${l.slug}/install`, { as: carol, method: 'POST' });
+ assert.strictEqual(r.status, 409);
+ });
+
+ await check('a session write from another site is refused', async () => {
+ const l = await fixtures.listed(t, alice, { title: 'A cross-site template' });
+ const r = await t.get(`/api/v1/listings/${l.slug}/sandbox`, { as: bob, method: 'POST', origin: 'https://evil.example' });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'exchange.cross_site');
+ });
+
+ await check('the installs page is complete without JavaScript and lists active and revoked', async () => {
+ const page = await t.get('/installs', { as: bob });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="ex-installs"'));
+ assert.ok(page.text.includes('id="ex-installs-active"'));
+ assert.ok(page.text.includes('id="ex-installs-revoked"'));
+ assert.ok(page.text.includes('ex-passport'));
+ assert.ok(page.text.includes('action="/installs/'));
+ assert.strictEqual(page.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual((await t.get('/installs')).status, 401);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The expand migration applies in one pass and creates exactly the tables the plan names, plus the SDK's account
+ * receipts and telemetry. The listing constraints are real: no money, a passport with an empty spend, a
+ * free/swap-only mode, and one active install / one open offer per person and listing.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('0001_initial.sql applies and creates the Exchange tables', async () => {
+ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
+ for (const table of ['exchange_listings', 'exchange_listing_tests', 'exchange_listing_events', 'exchange_sandbox_trials', 'exchange_installs', 'exchange_offers', 'exchange_outbox', 'account_data_events', 'telemetry_samples']) {
+ assert.ok(names.includes(table), `missing ${table}`);
+ }
+ });
+
+ await check('there is no money column on a listing', async () => {
+ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'exchange_listings'")).map((r) => r.column_name);
+ for (const forbidden of ['price', 'price_vibes', 'currency', 'billing_intent_id', 'amount']) assert.ok(!cols.includes(forbidden), forbidden);
+ assert.ok(cols.includes('passport'));
+ assert.ok(cols.includes('manifest'));
+ });
+
+ await check('a listing refuses an unknown kind and a spend in its passport', async () => {
+ const db = t.ctx.s.db;
+ const insert = (slug, kind, passport) => db.exec(
+ `INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, manifest, passport)
+ VALUES ($1, $2, 'A listing', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', $3, '{"name":"x","entry":"y"}'::jsonb, $4::jsonb)`,
+ [`exl_${ids.ulid()}`, slug, kind, JSON.stringify(passport)],
+ );
+ await assert.rejects(insert('bad-kind', 'notakind', { reads: [], writes: [], spends: [], contacts: [] }), /exchange_listings_kind/);
+ await assert.rejects(insert('bad-spend', 'template', { reads: [], writes: [], spends: ['billing.spend'], contacts: [] }), /exchange_listings_passport/);
+ await insert('bad-mode', 'template', { reads: [], writes: [], spends: [], contacts: [] });
+ await assert.rejects(db.exec("UPDATE exchange_listings SET mode = 'paid' WHERE slug = 'bad-mode'"), /exchange_listings_mode/);
+ await insert('bad-wants', 'template', { reads: [], writes: [], spends: [], contacts: [] });
+ await assert.rejects(db.exec("UPDATE exchange_listings SET wants = 'money' WHERE slug = 'bad-wants'"), /exchange_listings_wants/);
+ });
+
+ await check('the one-active-install and one-open-offer indexes are real', async () => {
+ const db = t.ctx.s.db;
+ const subject = ids.newId('user');
+ const at = new Date().toISOString();
+ const lid = `exl_${ids.ulid()}`;
+ await db.exec(`INSERT INTO exchange_listings (id, slug, title, mode, summary, license, version, creator, kind, status, listed_at, manifest, passport)
+ VALUES ($1, 'installable', 'Installable', 'free', 'A summary', 'CC0-1.0', '1.0.0', 'A maker', 'template', 'listed', $2, '{"name":"x","entry":"y"}'::jsonb, '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [lid, at]);
+ const iid = () => `exi_${ids.ulid()}`;
+ await db.exec(`INSERT INTO exchange_installs (id, listing_id, subject, version, passport) VALUES ($1, $2, $3, '1.0.0', '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [iid(), lid, subject]);
+ await assert.rejects(db.exec(`INSERT INTO exchange_installs (id, listing_id, subject, version, passport) VALUES ($1, $2, $3, '1.0.0', '{"reads":[],"writes":[],"spends":[],"contacts":[]}'::jsonb)`, [iid(), lid, subject]), /duplicate key|unique/i);
+ const oid = () => `exo_${ids.ulid()}`;
+ const offer = (id) => db.exec(`INSERT INTO exchange_offers (id, listing_id, from_subject, from_name, kind, body) VALUES ($1, $2, $3, 'A person', 'claim', 'I would like this')`, [id, lid, subject]);
+ await offer(oid());
+ await assert.rejects(offer(oid()), /duplicate key|unique/i);
+ });
+
+ await check('the conformance test rows are one per version and name', async () => {
+ const db = t.ctx.s.db;
+ const lid = await db.value("SELECT id FROM exchange_listings WHERE slug = 'installable'");
+ await db.exec("INSERT INTO exchange_listing_tests (listing_id, version, name, status) VALUES ($1, '1.0.0', 'manifest', 'passed')", [lid]);
+ await assert.rejects(db.exec("INSERT INTO exchange_listing_tests (listing_id, version, name, status) VALUES ($1, '1.0.0', 'manifest', 'failed')", [lid]), /duplicate key|unique/i);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * No looks in public AI text: a summary that names looks is replaced by the listing's title; an ordinary summary
+ * survives. Exchange v1 has no AI-written listing copy — this is the helper used the day a generated summary or
+ * sandbox line arrives, shared with Live, Community, MediaHub and Meme.
+ */
+const assert = require('assert');
+const { cleanSummary, hasLooks } = require('../server/text/no-looks');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('a summary that names looks becomes the title', async () => {
+ assert.strictEqual(cleanSummary('Starter site template', 'A shirtless man in a kitchen laughs.'), 'Starter site template');
+ assert.strictEqual(cleanSummary('Starter site template', 'A pretty girl shows the theme.'), 'Starter site template');
+ assert.ok(hasLooks('A shirtless man in a kitchen laughs.'));
+ });
+
+ await check('an ordinary summary survives', async () => {
+ const s = cleanSummary('Starter site template', 'A static starter site with one page and no tracking.');
+ assert.strictEqual(s, 'A static starter site with one page and no tracking.');
+ });
+
+ const t = await boot();
+ try {
+ await check('a creator\'s own listing words are not rewritten by noLooks', async () => {
+ const user = t.network.addUser('maker');
+ const r = await t.get('/api/v1/listings', {
+ as: user,
+ json: {
+ title: 'A portrait lighting theme', kind: 'theme', mode: 'free',
+ summary: 'A warm portrait theme; the maker describes the lighting, not a person.',
+ license: 'MIT', version: '1.0.0',
+ manifest: { name: 'Portrait lighting', entry: 'theme.json', runtime: 'css' },
+ passport: { reads: [], writes: [], spends: [], contacts: [] },
+ },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().summary, 'A warm portrait theme; the maker describes the lighting, not a person.');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * Free and swap: a free listing takes a claim, a swap listing takes an offer with what is given in return; only the
+ * owner accepts or declines, only the sender withdraws; accepting closes the listing as given or swapped. No money
+ * in an offer, and a prohibited item is refused.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ try {
+ const free = await fixtures.listed(t, alice, { title: 'A free starter theme', kind: 'theme' });
+ const swap = await fixtures.listed(t, alice, { title: 'A surf map to swap', kind: 'game_asset', mode: 'swap', wants: 'A type kit' });
+ let claim = null;
+
+ await check('a free listing takes a claim; the owner can read it and a duplicate is refused', async () => {
+ const r = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'I would use this for my first site.' } });
+ assert.strictEqual(r.status, 201, r.text);
+ claim = r.json();
+ assert.match(claim.id, /^exo_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(claim.kind, 'claim');
+ assert.strictEqual(claim.status, 'open');
+ const dup = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: bob, json: { body: 'Again' } });
+ assert.strictEqual(dup.status, 409);
+ assert.strictEqual(dup.json().code, 'exchange.offer_exists');
+ const own = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice, json: { body: 'Mine' } });
+ assert.strictEqual(own.status, 400);
+ assert.strictEqual(own.json().code, 'exchange.own_listing');
+ const ownerRead = await t.get(`/api/v1/listings/${free.slug}/offers`, { as: alice });
+ assert.strictEqual(ownerRead.status, 200);
+ assert.strictEqual(ownerRead.json().count, 1);
+ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}/offers`, { as: carol })).status, 403);
+ const mine = await t.get('/api/v1/offers', { as: bob });
+ assert.strictEqual(mine.json().sent.length, 1);
+ const owner = await t.get('/api/v1/offers', { as: alice });
+ assert.strictEqual(owner.json().received.length, 1);
+ });
+
+ await check('only the owner accepts; accepting closes the free listing as given and tombstones it', async () => {
+ const notOwner = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: bob, method: 'POST' });
+ assert.strictEqual(notOwner.status, 403);
+ const accepted = await t.get(`/api/v1/offers/${claim.id}/accept`, { as: alice, method: 'POST' });
+ assert.strictEqual(accepted.status, 200, accepted.text);
+ assert.strictEqual(accepted.json().offer.status, 'accepted');
+ assert.strictEqual(accepted.json().listing.status, 'closed');
+ assert.strictEqual(accepted.json().listing.closed_reason, 'given');
+ assert.strictEqual((await t.get(`/api/v1/listings/${free.slug}`)).status, 404);
+ const topics = (await t.ctx.s.db.many('SELECT topic FROM exchange_outbox ORDER BY created_at')).map((x) => x.topic);
+ assert.ok(topics.includes('exchange.index_document.deleted'));
+ });
+
+ await check('a swap offer must say what is given; accepting closes it as swapped', async () => {
+ const noGive = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I want this' } });
+ assert.strictEqual(noGive.status, 400);
+ const r = await t.get(`/api/v1/listings/${swap.slug}/offers`, { as: carol, json: { body: 'I have just the kit', give: 'A 12-column grid kit and an icon set' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const offer = r.json();
+ assert.strictEqual(offer.kind, 'swap');
+ const accepted = await t.get(`/api/v1/offers/${offer.id}/accept`, { as: alice, method: 'POST' });
+ assert.strictEqual(accepted.status, 200);
+ assert.strictEqual(accepted.json().listing.closed_reason, 'swapped');
+ });
+
+ await check('withdraw and decline work for the right person; a decided offer is unchanged', async () => {
+ const l = await fixtures.listed(t, alice, { title: 'A withdrawable theme' });
+ const r = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I would like it' } });
+ const offer = r.json();
+ assert.strictEqual((await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: carol, method: 'POST' })).status, 403);
+ const w = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
+ assert.strictEqual(w.status, 200);
+ assert.strictEqual(w.json().offer.status, 'withdrawn');
+ const again = await t.get(`/api/v1/offers/${offer.id}/withdraw`, { as: bob, method: 'POST' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().offer.status, 'withdrawn');
+
+ const r2 = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'Me instead' } });
+ const o2 = r2.json();
+ assert.strictEqual((await t.get(`/api/v1/offers/${o2.id}/decline`, { as: bob, method: 'POST' })).status, 403);
+ const d = await t.get(`/api/v1/offers/${o2.id}/decline`, { as: alice, method: 'POST' });
+ assert.strictEqual(d.status, 200);
+ assert.strictEqual(d.json().offer.status, 'declined');
+ });
+
+ await check('an offer carrying money or a prohibited item is refused', async () => {
+ const l = await fixtures.listed(t, alice, { title: 'A refusal theme' });
+ const money = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: bob, json: { body: 'I will pay you $30 by paypal.' } });
+ assert.strictEqual(money.status, 400);
+ assert.strictEqual(money.json().code, 'exchange.prohibited');
+ const weapon = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'I can swap a firearm.' } });
+ assert.strictEqual(weapon.status, 400);
+ assert.strictEqual(weapon.json().code, 'exchange.prohibited');
+ const shortGive = await t.get(`/api/v1/listings/${l.slug}/offers`, { as: carol, json: { body: 'A claim with a tiny note', give: 'ok' } });
+ assert.strictEqual(shortGive.status, 400);
+ assert.strictEqual(shortGive.json().code, 'exchange.invalid');
+ });
+
+ await check('the offers page is server-rendered and lists both directions', async () => {
+ const page = await t.get('/offers', { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="ex-offers"'));
+ assert.ok(page.text.includes('id="ex-offers-received"'));
+ assert.ok(page.text.includes('id="ex-offers-sent"'));
+ assert.strictEqual((await t.get('/offers')).status, 401);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,173 @@
@@ -0,0 +1,173 @@
+'use strict';
+/**
+ * Server-rendered pages: the home (hero, three scenes, fresh listings), browse and filters, one listing with its
+ * passport and tests, the submit form, the sandbox trial, installations, offers, safety, developers, 404, cache
+ * headers, content-hashed one-comment assets, and the discovery artifacts.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+const { assetVersion } = require('../server/render/layout');
+
+function jsonLd(html) {
+ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+}
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const listing = await fixtures.listed(t, alice, {
+ title: 'A page-tested template',
+ summary: 'A template that the pages can show.',
+ description: 'Line one.\nLine two.',
+ });
+ try {
+ await check('home: the hero, the three scenes and a fresh listing, public with Vary: Cookie', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ for (const marker of ['id="ex-home"', 'id="ex-listings"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Trade what people make', 'A page-tested template', '/submit', '/safety']) {
+ assert.ok(r.text.includes(marker), marker);
+ }
+ const types = jsonLd(r.text).map((x) => x['@type']);
+ assert.ok(types.includes('WebSite'), types.join(','));
+ assert.ok(types.includes('WebApplication'), types.join(','));
+ assert.strictEqual((await t.get('/', { as: alice })).headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('/listings filters and lists; a public listing page carries the passport and tests', async () => {
+ const browse = await t.get('/listings?mode=free&kind=template');
+ assert.strictEqual(browse.status, 200);
+ assert.strictEqual(browse.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(browse.text.includes('id="ex-listings-page"'));
+ assert.ok(browse.text.includes('id="ex-filters"'));
+ assert.ok(browse.text.includes('A page-tested template'));
+ const detail = await t.get(`/listings/${listing.slug}`);
+ assert.strictEqual(detail.status, 200);
+ assert.strictEqual(detail.headers.get('cache-control'), 'public, max-age=60');
+ for (const marker of ['id="ex-listing"', 'id="ex-about"', 'id="ex-passport"', 'id="ex-tests"', 'id="ex-sandbox"', 'id="ex-install"', 'id="ex-manifest"', 'ex-passport-reads', 'CC0-1.0', 'Line one.']) {
+ assert.ok(detail.text.includes(marker), marker);
+ }
+ assert.ok(!detail.text.includes('id="ex-offer-form"'), 'a guest gets a sign-in prompt, not the form');
+ const signed = await t.get(`/listings/${listing.slug}`, { as: bob });
+ assert.strictEqual(signed.status, 200);
+ assert.ok(signed.text.includes('id="ex-offer-form"'));
+ assert.ok(signed.text.includes('Run the sandbox'));
+ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('the submit form is plain HTML; a form POST saves a draft and redirects to it', async () => {
+ const form = await t.get('/submit');
+ assert.strictEqual(form.status, 200);
+ for (const marker of ['id="ex-submit"', 'id="ex-listing-form"', 'name="passport_reads"', 'name="manifest_entry"', 'name="mode"', 'nothing', 'no money']) assert.ok(form.text.toLowerCase().includes(marker.toLowerCase()), marker);
+ const r = await t.get('/submit', {
+ as: alice,
+ form: {
+ title: 'A form-made theme', kind: 'theme', mode: 'free', summary: 'A theme made through the form.',
+ description: 'It has a description.', license: 'MIT', version: '1.0.0',
+ manifest_name: 'Form theme', manifest_entry: 'theme.json', manifest_runtime: 'css',
+ passport_reads: 'search.read\nspace.files.read', passport_writes: 'space.files.write', passport_contacts: 'https://openvibe.space',
+ },
+ });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.match(r.headers.get('location'), /^\/listings\/a-form-made-theme\?submitted=1$/);
+ const page = await t.get(r.headers.get('location'), { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="ex-owner"'));
+ assert.ok(page.text.includes('Draft'));
+ assert.ok(page.text.includes('id="ex-passport"'));
+ assert.strictEqual((await t.get('/submit', { as: alice, form: { title: 'x' } })).status, 400);
+ });
+
+ await check('a form POST from another site is refused', async () => {
+ const r = await t.get('/submit', {
+ as: alice, origin: 'https://evil.example',
+ form: { title: 'A cross-site theme', kind: 'theme', mode: 'free', summary: 'A theme.', license: 'MIT', version: '1.0.0', manifest_name: 'X', manifest_entry: 'x' },
+ });
+ assert.strictEqual(r.status, 403);
+ assert.ok(r.text.includes('id="ex-not-found"') || r.text.includes('That came from somewhere else'));
+ });
+
+ await check('the sandbox form redirects to a stable trial page; install and revoke work by form', async () => {
+ const sb = await t.get(`/listings/${listing.slug}/sandbox`, { as: bob, method: 'POST' });
+ assert.strictEqual(sb.status, 303);
+ assert.match(sb.headers.get('location'), /^\/sandbox\/exs_/);
+ const page = await t.get(sb.headers.get('location'), { as: bob });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="ex-sandbox-trial"'));
+ assert.ok(page.text.includes('The dry run passed'));
+ const ins = await t.get(`/listings/${listing.slug}/install`, { as: bob, method: 'POST' });
+ assert.strictEqual(ins.status, 303);
+ assert.strictEqual(ins.headers.get('location'), '/installs?installed=1');
+ const installs = await t.get('/installs', { as: bob });
+ assert.ok(installs.text.includes('id="ex-installs"'));
+ assert.ok(installs.text.includes('Revoke'));
+ const id = await t.ctx.s.db.value("SELECT id FROM exchange_installs WHERE status = 'active' LIMIT 1");
+ const rev = await t.get(`/installs/${id}/revoke`, { as: bob, method: 'POST' });
+ assert.strictEqual(rev.status, 303);
+ assert.strictEqual(rev.headers.get('location'), '/installs?revoked=1');
+ assert.strictEqual((await t.ctx.s.db.value('SELECT status FROM exchange_installs WHERE id = $1', [id])), 'revoked');
+ });
+
+ await check('the claim form creates a free claim; the owner sees it on the listing page', async () => {
+ const claim = await t.get(`/listings/${listing.slug}/offers`, { as: bob, form: { body: 'I would like to use this.' } });
+ assert.strictEqual(claim.status, 303);
+ assert.strictEqual(claim.headers.get('location'), `/listings/${listing.slug}?offered=1`);
+ const owner = await t.get(`/listings/${listing.slug}`, { as: alice });
+ assert.ok(owner.text.includes('id="ex-offers"'));
+ assert.ok(owner.text.includes('I would like to use this.'));
+ assert.ok(owner.text.includes('Accept the claim'));
+ });
+
+ await check('safety, developers, 404 and the submit sign-in prompt', async () => {
+ const safety = await t.get('/safety');
+ assert.strictEqual(safety.status, 200);
+ assert.ok(safety.text.includes('id="ex-safety"'));
+ assert.ok(safety.text.includes('No money, anywhere'));
+ const dev = await t.get('/developers');
+ assert.strictEqual(dev.status, 200);
+ assert.ok(dev.text.includes('id="ex-developers"'));
+ assert.ok(dev.text.includes('/api/v1/listings'));
+ assert.ok(dev.text.includes('network.grants.write'));
+ const nf = await t.get('/no/such/page');
+ assert.strictEqual(nf.status, 404);
+ assert.match(nf.headers.get('content-type'), /^text\/html/);
+ assert.ok(nf.text.includes('id="ex-not-found"'));
+ const prompt = await t.get('/submit', { as: null });
+ assert.ok(prompt.text.includes('id="ex-listing-form"'), 'a guest can read the form');
+ const install = await t.get('/installs');
+ assert.strictEqual(install.status, 401);
+ assert.ok(install.text.includes('Sign in with OpenVibe'));
+ });
+
+ await check('assets are content-hashed and served; robots and sitemap list public listings', async () => {
+ const v = assetVersion('css/exchange.css');
+ const css = await t.get(`/css/exchange.css?v=${v}`);
+ assert.strictEqual(css.status, 200);
+ assert.strictEqual(css.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ assert.match(css.text, /OpenVibe\.Exchange: the room's own pieces/, 'the designed stylesheet');
+ const wrong = await t.get('/css/exchange.css?v=deadbeefdeadbeef');
+ assert.strictEqual(wrong.status, 200);
+ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
+ const js = await t.get('/js/exchange.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ assert.strictEqual(js.text.trim().split('\n').length, 1);
+ const robots = await t.get('/robots.txt');
+ assert.match(robots.text, /Disallow: \/api\//);
+ const map = await t.get('/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.ok(map.text.includes(`https://openvibe.exchange/listings/${listing.slug}`));
+ const llms = await t.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.ok(llms.text.includes('OpenVibe.Exchange'));
+ const full = await t.get('/llms-full.txt');
+ assert.ok(full.text.includes(listing.slug));
+ });
+ } finally {
+ await t.close();
+ }
+ 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 and an
+ * in-process mock of OpenVibe.Network; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- installs # 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,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * The free/swap safety rules: the fixed prohibited-items list, the no-money rule, and cleanText. A legitimate
+ * phrase such as "a cash register icon pack" is not refused; a price or a payment request is.
+ */
+const assert = require('assert');
+const { findProhibited, refusals, cleanText, SAFETY, PROHIBITED, MONEY_AMOUNT } = require('../server/safety');
+const { check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('the prohibited list catches the plain cases', async () => {
+ assert.strictEqual(findProhibited('A shotgun for swap').code, 'weapons');
+ assert.strictEqual(findProhibited('Cocaine, cheap').code, 'drugs');
+ assert.strictEqual(findProhibited('A ransomware builder').code, 'malware');
+ assert.strictEqual(findProhibited('Escort services').code, 'sexual');
+ assert.strictEqual(findProhibited('A forged passport').code, 'forged');
+ assert.strictEqual(findProhibited('A kitten').code, 'animals');
+ assert.strictEqual(findProhibited('Yours for $20').code, 'money');
+ assert.strictEqual(findProhibited('Pay by PayPal').code, 'money');
+ assert.ok(PROHIBITED.length >= 6);
+ assert.ok(SAFETY.includes('no payments') || SAFETY.includes('has no payments'));
+ });
+
+ await check('ordinary maker copy is not refused', async () => {
+ assert.strictEqual(findProhibited('A cash register icon pack for a shop theme'), null);
+ assert.strictEqual(findProhibited('A 12-column grid and a type kit'), null);
+ assert.strictEqual(findProhibited('A surf map with three ramps'), null);
+ assert.strictEqual(findProhibited(''), null);
+ assert.ok(MONEY_AMOUNT.test('€15'));
+ assert.ok(!MONEY_AMOUNT.test('version 15'));
+ });
+
+ await check('refusals carry the API code, and cleanText normalises plain text', async () => {
+ const hit = refusals('A theme', 'and a firearm');
+ assert.strictEqual(hit.status, 400);
+ assert.strictEqual(hit.code, 'exchange.prohibited');
+ assert.ok(hit.message.includes('weapons'));
+ assert.strictEqual(refusals('A theme'), null);
+ assert.strictEqual(cleanText(' a\r\nb\u0000 ', 20), 'a\nb');
+ assert.strictEqual(cleanText('abcdef', 3), 'abc');
+ });
+
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * The Search Fabric document: owner exchange, type listing, id exl_…, canonical /listings/<slug>, title/summary,
+ * facets kind/mode/licence/creator and the passport permissions, authorship human, the listing's revision. A draft
+ * writes nothing; publishing upserts; closing or hiding writes a valid tombstone.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const fixtures = require('./helpers/fixtures');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ try {
+ const l = await fixtures.listed(t, alice, { title: 'A searchable template' });
+
+ await check('publishing writes a valid search.index-document@1', async () => {
+ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
+ assert.ok(row, 'an upsert row exists');
+ const doc = row.payload;
+ const v = contracts.validate('search.index-document@1', doc);
+ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
+ assert.strictEqual(doc.owner, 'exchange');
+ assert.strictEqual(doc.type, 'listing');
+ assert.strictEqual(doc.id, l.id);
+ assert.strictEqual(doc.revision, l.revision);
+ assert.strictEqual(doc.deleted, false);
+ assert.strictEqual(doc.visibility, 'public');
+ assert.strictEqual(doc.publication_state, 'published');
+ assert.strictEqual(doc.canonical_url, `https://openvibe.exchange/listings/${l.slug}`);
+ assert.strictEqual(doc.title, 'A searchable template');
+ assert.strictEqual(doc.facets.kind, 'template');
+ assert.strictEqual(doc.facets.mode, 'free');
+ assert.strictEqual(doc.facets.license, 'CC0-1.0');
+ assert.deepStrictEqual(doc.facets.can_read, ['search.read']);
+ assert.strictEqual(doc.authorship, 'human');
+ assert.ok(doc.body.includes('search.read'));
+ assert.ok(doc.body.includes('Licence: CC0-1.0'));
+ });
+
+ await check('a draft writes no document; closing sends a valid tombstone with the next revision', async () => {
+ const draft = await fixtures.create(t, alice, { title: 'A never-listed theme' });
+ assert.strictEqual((await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE payload->>\'id\' = $1', [draft.id])), 0);
+ const r = await t.get(`/api/v1/listings/${l.slug}/close`, { as: alice, method: 'POST', json: { reason: 'withdrawn' } });
+ assert.strictEqual(r.status, 200);
+ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
+ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));
+ assert.strictEqual(tomb.payload.deleted, true);
+ assert.strictEqual(tomb.payload.id, l.id);
+ assert.strictEqual(tomb.payload.revision, r.json().revision);
+ });
+
+ await check('a staff hide also writes a tombstone and the public list stays empty', async () => {
+ const l2 = await fixtures.listed(t, alice, { title: 'A hideable search theme' });
+ const r = await t.get(`/api/v1/listings/${l2.slug}/hide`, { as: mod, method: 'POST' });
+ assert.strictEqual(r.status, 200);
+ const row = await t.ctx.s.db.maybe("SELECT payload FROM exchange_outbox WHERE topic = 'exchange.index_document.deleted' AND payload->>'id' = $1", [l2.id]);
+ assert.ok(row);
+ assert.strictEqual(row.payload.revision, r.json().revision);
+ const pub = await t.get('/api/v1/listings?limit=50');
+ assert.strictEqual(pub.json().count, 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * The first proof: a licensed template listed, tried in the sandbox, installed with its passport's grants and
+ * revoked cleanly; a second run changes nothing. The seed uses the same store functions as the API and pages.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { runSeed, SLUG, LICENSE } = require('../scripts/seed-proof');
+
+(async () => {
+ const t = await boot();
+ try {
+ let out = null;
+ await check('the seed lists a CC0 template, tries it, installs and revokes it', async () => {
+ out = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
+ assert.strictEqual(out.created, true);
+ assert.strictEqual(out.listing.slug, SLUG);
+ assert.strictEqual(out.listing.status, 'listed');
+ assert.strictEqual(out.listing.mode, 'free');
+ assert.strictEqual(out.listing.license, LICENSE);
+ assert.strictEqual(out.listing.tests.verified, true);
+ assert.strictEqual(out.trial.status, 'passed');
+ assert.deepStrictEqual(out.install.passport.reads, ['search.read']);
+ assert.strictEqual(out.revoked.status, 'revoked');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listing_tests WHERE listing_id = $1 AND status = \'passed\'', [out.listing.id])), 4);
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM exchange_outbox WHERE topic = 'exchange.index_document.upserted'")), 1);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
+ });
+
+ await check('the public page and API show it; a second run adds nothing', async () => {
+ assert.strictEqual((await t.get(`/listings/${SLUG}`)).status, 200);
+ const api = await t.get(`/api/v1/listings/${SLUG}`);
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(api.json().license, LICENSE);
+ const again = await runSeed({ config: t.config, s: t.ctx.s, log: { log() {} } });
+ assert.strictEqual(again.created, false);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_listings')), 1);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM exchange_installs')), 1);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -0,0 +1,147 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id exchange, cookies exchange_at/exchange_rt/exchange_oauth), the session-token
+ * rules (a FedCM assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback
+ * metrics, pages without JavaScript, and cross-site sign-out.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { sanitizeNext } = require('../server/auth/sso');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+
+ await check('login redirects to Network with state and an S256 code challenge, client id exchange', async () => {
+ const r = await t.get('/auth/login?next=/listings');
+ 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'), 'exchange');
+ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
+ assert.match(r.headers.get('set-cookie'), /exchange_oauth=.*Path=\/auth.*HttpOnly/i);
+ });
+
+ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
+ const login = await t.get('/auth/login?next=/listings');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(r.status, 302, r.text);
+ assert.strictEqual(r.headers.get('location'), '/listings');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /exchange_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /exchange_rt=[^;]+;.*HttpOnly/i);
+ assert.match(set, /exchange_oauth=;/);
+ const at = set.match(/exchange_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `exchange_at=${at}` });
+ assert.strictEqual(me.json().user.username, 'kim');
+ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
+ });
+
+ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).status, 400);
+ 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, 400);
+ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).status, 400);
+ });
+
+ await check('next= only accepts same-site paths', async () => {
+ const r = await t.get('/auth/login?next=//evil.example/x');
+ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/exchange_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://openvibe.exchange' };
+ assert.strictEqual(sanitizeNext('/listings?x=1', config), '/listings?x=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.exchange/listings/x?y=1', config), '/listings/x?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.exchange.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ });
+
+ 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, 'exchange');
+ 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 ['/', '/listings', '/safety', '/developers', '/submit']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript><nav/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
+ const ping = await t.get('/api/v1/ping');
+ assert.strictEqual(ping.status, 200);
+ assert.deepStrictEqual(ping.json(), { ok: true, service: 'exchange' });
+ const gone = await t.get('/api/v1/nope');
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(gone.json().code, 'route.not_found');
+ });
+
+ await check('a FedCM assertion and an app token are not sessions', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `exchange_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `exchange_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: `exchange_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: 'exchange_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'exchange_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(!/exchange_at=;|exchange_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=/listings', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/listings');
+ assert.match(String(same.headers.get('set-cookie') || ''), /exchange_at=;/);
+ });
+
+ await t.close();
+ done();
+})();