openvibers/openvibe.vision Commits
OpenVibe.Vision v1
Changes
nnew file mode 100644@@@ -0,0 +1,71 @@++# OpenVibe.Vision — environment (production: /etc/openvibe/vision.env). Names only; never commit values.++++NODE_ENV=production++PORT=5130++HOST=127.0.0.1++BASE_URL=https://openvibe.vision++TRUST_PROXY=2++# PostgreSQL (ADR-035): the runtime role through PgBouncer and the owner on a direct connection for migrations.++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:vision:++# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).++#VISION_LIMITS_MINUTE=60++#VISION_LIMITS_HOUR=600++#VISION_API_RATE_LIMIT_PER_MIN=120++#VISION_RATE_LIMIT_PER_MIN=180++++# OpenVibe.Network: SSO (OAuth client `vision`, PKCE S256, cookies vision_at/vision_rt/vision_oauth) and its JWKS;++# service/app tokens for audience openvibe.vision.++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=vision++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.vision/auth/callback++# VISION_AUDIENCE=openvibe.vision++# OV_SESSION_AUDIENCE=openvibe.network++# VISION_PGLITE_DIR=./data/pglite++++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).++VISION_EVENTS_SECRET=++VISION_EVENTS_URL=++#VISION_EVENTS_ENDPOINT=http://127.0.0.1:5130/internal/events++#VISION_EVENTS_INTERVAL_MS=5000++++# OpenVibe.AI: the only model gateway. A first-party service token for client `vision` (Network grant++# ai.run.create, namespace ai.*) makes the run; AI's routing, quotas, cache and cost accounting apply. Vision++# holds no provider key and calls no paid provider directly. Unset the client secret and only local engines serve.++OV_AI_URL=https://ai.openvibe.services++# VISION_AI_AUDIENCE=openvibe.ai++# VISION_AI_SCOPE=ai.run.create++# VISION_AI_NAMESPACE=ai.*++# VISION_AI_MODELS=openai:gpt-5-mini,local:qwen++#VISION_AI_TIMEOUT_MS=45000++#VISION_AI_WAIT_MS=30000++#VISION_AI_MAX_TOKENS=700++#VISION_AI_TEMPERATURE=0.1++++# The local OCR engine (Tesseract 5). A missing binary is an honest 503; no cloud fallback without consent.++VISION_TESSERACT=tesseract++#VISION_OCR_LANG=eng++#VISION_OCR_TIMEOUT_MS=20000++++# Image inputs: upload, data URL or public URL. URLs are fetched only after every resolved address is public.++#VISION_IMAGE_MAX_MB=8++#VISION_IMAGE_TIMEOUT_MS=10000++#VISION_ALLOW_PRIVATE_URLS=0++++# The UI check: HTML, URL or screenshot. It never executes JavaScript and never fetches remote stylesheets.++#VISION_UI_MAX_HTML_KB=1024++#VISION_UI_TIMEOUT_MS=10000++#VISION_UI_MAX_FINDINGS=200++++# A restore drill starts no account subscriptions.++#VISION_DRILL=0++++# optional: defaults to true in production++# COOKIE_SECURE=truennew file mode 100644@@@ -0,0 +1,18 @@++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.++ # The local OCR tests need Tesseract 5; install it on a runner that lacks it.++ test: 'command -v tesseract >/dev/null || (sudo apt-get update && sudo apt-get install -y tesseract-ocr); 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,37 @@++# Changelog++++What changed in OpenVibe.Vision, newest first.++++## 0.1.0 — 2026-10-28++++The first version: see with the best model for the job.++++- **Ask a picture.** `POST /api/v1/ask` takes a multipart image, an image data URL or a public `image_url`, plus a++ question and one of `ask|describe|identify|count|translate|ocr`. A read question is answered by the local OCR++ reader first; anything else goes through OpenVibe.AI. The answer names the engine, model, provider, route, reason,++ cost and measured latency.++- **Local OCR first.** `POST /api/v1/ocr` runs Tesseract 5 on this host (one OpenMP thread, a temporary file that is++ removed), returns the text, mean confidence and word boxes, and costs zero. `fallback=1` asks AI only when the++ local pass found no usable text; a missing binary is an honest 503, never a silent cloud call.++- **Compare side by side.** `POST /api/v1/compare` runs the local reader and up to three AI hints on the same image.++ The requested model is only a hint: the provider and model reported are AI's own provenance. Each row carries++ success, text, cost and latency; `GET /api/v1/benchmarks` aggregates them per engine/model without subjects.++- **The UI check.** `POST /api/v1/ui-check` reads HTML, a public page URL or a screenshot locally. The static pass++ estimates boxes and inline CSS to find overflow, cut-off text, WCAG contrast failures, overlapping positioned++ boxes and layering faults (`z-index` without position, a full-viewport fixed box). The screenshot pass uses the++ local OCR boxes and sharp pixels. Findings are data: kind, severity, selector, detail, evidence. Design's Arena++ calls it with `vision.ui_check`.++- **The AI gateway.** Vision calls OpenVibe.AI's OpenAI-compatible endpoint with a first-party service token++ (`ai.run.create`, namespace `ai.*`). AI's routing, quotas, cache, provenance and cost accounting apply. Vision holds++ no provider key, adds no provider and calls no paid provider directly.++- **No looks, no faces.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by++ a neutral sentence. Face recognition is not implemented and is not called, ever.++- **Privacy.** No image bytes are stored: an answer keeps a sha256, the media type, the cleaned text and the model++ provenance. Answers, compare samples and UI checks are exported and erased with the person's account.++- **The service.** Network sign-in as OAuth client `vision` (PKCE S256, `vision_at`/`vision_rt`/`vision_oauth`),++ truthful readiness, Prometheus metrics on loopback, discovery, a systemd unit, an nginx vhost and CI. Pages are++ server-rendered with stable `vi-` ids/classes; `public/css/vision.css` and `public/js/vision.js` are++ one-comment placeholders for the main session.++- **Tests.** Migration, SSO, API, OCR, compare/benchmarks, the UI check (including a planted overflow), noLooks,++ pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock++ Network, a mock OpenVibe.AI and local image/page fixtures.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,183 @@++# OpenVibe.Vision++++> See with the best model for the job.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Vision answers questions about a picture. The cheapest engine goes first: the local Tesseract reader++answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run++through the local reader and up to three AI model hints side by side, with the model that actually answered, its++cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns++overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No face recognition, ever.**++++## v1 scope (plan §5.8)++++- **Ask a picture.** Describe, read, identify, count, translate. Upload a file, send a data URL, or paste a public++ image URL. Every answer reports the engine, model, provider, route, why it was chosen, the cost and the latency.++- **OCR with a local engine first.** Tesseract 5 runs on this host; no image leaves it and no model is called. An++ AI fallback is opt-in (`fallback=1`) and runs only when the local pass found no usable text.++- **Compare models side by side.** One image, the local reader and one to three AI model hints. AI's router is the++ authority: `requested_model` is only the hint, `model`/`provider` are what AI says answered. Each sample records++ cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.++- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding++ `vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.++- **No faces, no looks.** No face recognition is implemented or called; the prompt also forbids naming or identifying a person. Public AI text is passed through `noLooks`;++ an answer that names a person's looks is replaced by a neutral sentence.++- **Privacy.** No image bytes are stored: a sha256, media type and byte count are. Answers, compare samples and UI++ checks are exported and erased with the person's account.++- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no++ public records, so it writes no Search document and has no outbox.++++## What runs where++++| Step | Where | Cost | Leaves the host? |++|---|---|---|---|++| Image decode, rotate, resize, sha256 | this host, `sharp` | 0 | no |++| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no |++| UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) |++| UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |++| Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI |++| OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent |++| Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |++++Vision holds no provider key, adds no provider and calls no paid provider directly. `ai.run.create` is the only++outbound capability; AI's quotas and its own route decide whether a run is allowed.++++## Owns++++| Table | Holds | Migration |++|---|---|---|++| `vision_answers` | `vis_` + ULID, subject (`usr_…` or null), requester, task `ask\|describe\|ocr\|compare`, question, mode, source, public `source_url`, image sha256/mime/bytes, cleaned `text`, `answer` jsonb (engine, reason, words, samples, run id), model/provider/route, cost, latency | `migrations/0001_initial.sql` |++| `vision_compare_samples` | `vcm_` + ULID, answer, subject, task, engine `local\|ai`, requested model, answered model/provider/route, ok, text, cost, latency, error code | `0001_initial.sql` |++| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `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` |++++No table stores image bytes, a token or a provider key.++++## Does not own++++- **Models, routing, quotas and provider keys** — OpenVibe.AI. Vision makes first-party `ai.run.create` runs with++ namespace `ai.*`; AI decides the provider/model and reports the provenance.++- **Identity, roles and accounts** — OpenVibe.Network. Vision verifies session and service tokens against its JWKS.++- **The local OCR binary and language data** — the host (Tesseract under `/usr/bin` and `/usr/share`).++- **Search** — OpenVibe.Search. v1 has no public records to index.++- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.++++## Capabilities and grants++++The service offers four capabilities. The main session registers their manifests and grants them; v1 checks the++grant list directly, so a token that already carries one works.++++| Capability | Routes | Typical caller |++|---|---|---|++| `vision.ask` | `POST /api/v1/ask`, `GET /api/v1/answers/:id` | a first-party service or a person |++| `vision.ocr` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person |++| `vision.compare` | `POST /api/v1/compare` | a first-party service or a person |++| `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |++++Outgoing: **`ai.run.create`** to OpenVibe.AI (audience `openvibe.ai`, namespace `ai.*`), minted by Network for the++client `vision`. No new AI capability, no provider key, no direct paid provider.++++## API++++JSON under `/api/v1`; errors are `application/problem+json` with a `code`. An image is a multipart `image` field, a++JSON `image` data URL, or a JSON `image_url` public URL. A person is the `vision_at` session cookie or a Network user++token as a Bearer. A session write must come from `https://openvibe.vision` (403 `vision.cross_site`). A service token++must hold the route's capability (403 `capability.denied`).++++| Route | What it does |++|---|---|++| `POST /api/v1/ask` (alias `POST /api/v1/describe`) | `{ task?, question, mode?, lang?, model?, image_url? }` or multipart `image`; returns `{ answer }`. A read question is local-first, otherwise AI. |++| `POST /api/v1/ocr` | multipart/data URL/URL; `{ fallback?, lang?, model? }`; returns the OCR row with `text`, `answer.confidence`, `answer.words`, cost 0. |++| `POST /api/v1/compare` | `{ task?, question?, models?: string[] }` plus an image; returns `{ answer, compare }`, one row per engine. |++| `POST /api/v1/ui-check` | `{ html }`, `{ url }`, or a screenshot plus `{ viewport: { w, h } }`; returns `{ check }` with `findings`, `counts`, `severity`, `warnings`. |++| `GET /api/v1/answers/:id` | one saved answer: its owner, or a service holding `vision.ask`. |++| `GET /api/v1/ui-checks/:id` | one saved UI check: its owner, or a service holding `vision.ui.check`. |++| `GET /api/v1/models` | AI's offered `provider:model` hints and the local engine. No subject data. |++| `GET /api/v1/benchmarks?task=&limit=` | aggregate compare samples: task, engine, provider, model, samples, mean latency, mean cost. No subject or answer text. |++| `GET /api/v1/ping`, `/api/health`, `/api/ready`, `/release.json`, `/metrics` | service endpoints; readiness and metrics are truthful, metrics loopback-only. |++++Example — the call Design's Arena makes:++++```sh++curl -s -X POST https://openvibe.vision/api/v1/ui-check \++ -H "Authorization: Bearer $VISION_SERVICE_TOKEN" \++ -H 'Content-Type: application/json' \++ -d '{"html":"<!doctype html><style>.card{width:1400px}</style><div class=card>x</div>","viewport":{"w":390,"h":844}}'++```++++Answer (abridged):++++```json++{ "check": {++ "engine": "static-html",++ "viewport": { "w": 390, "h": 844 },++ "counts": { "overflow": 1, "contrast": 1 },++ "severity": { "high": 1, "medium": 1 },++ "findings": [++ { "kind": "overflow", "severity": "medium", "selector": "div.card", "detail": "the box is 1400px wide and reaches 1400px in a 390px viewport", "evidence": { "width": 1400, "right": 1400, "viewport": 390 } }++ ]++}}++```++++Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,++`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,++`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`.++++## Pages++++All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML++with stable `vi-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in++person is `private, no-store`.++++| Page | What it is |++|---|---|++| `/` | the room: hero, ask, compare and UI-check scenes, and a try section |++| `/ask` | upload or URL + a question (POST → `/a/:id`) |++| `/ocr` | local OCR, with an inline reading for a guest and a saved answer for a person |++| `/compare` | local OCR vs AI model hints (POST → `/a/:id`) |++| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person |++| `/a/:id` | one saved answer (owner or staff) |++| `/ui/:id` | one saved UI check (owner or staff) |++| `/developers` | the API, capabilities and what leaves the host |++| 404 | the not-found page |++++`public/css/vision.css` and `public/js/vision.js` are one-comment placeholder files: the pages work without them and++the main session designs them. `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list only the public++pages; `/a/`, `/ui/`, `/auth/`, `/api/` and `/internal` are not indexed.++++## The v1 proof++++`node scripts/proof.js` is offline: it renders a receipt, reads it with local Tesseract, and checks a page with a++planted 1400 px overflow, a clipped nowrap line and low contrast. It exits non-zero if the local reader misses the++total or the UI check misses a fault. The tests do the same over the API, and `test/compare.test.js` runs two AI++hints and the local reader on one image with measured cost and latency.++++## Run and test++++```sh++npm install++npm test # PGlite; every file in its own process, no network needed++npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)++OV_ALLOW_SUITE=1 npm test++npm start # PORT 5130, loopback; DATABASE_URL or an embedded PGlite in development++node scripts/proof.js # local OCR + a planted UI overflow, no network and no model++```++++The tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, the++API and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (a++planted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account++export/deletion and the image SSRF guard.++++## Deploy++++`deploy/systemd/openvibe-vision.service` (127.0.0.1:5130, `/etc/openvibe/vision.env`) and++`deploy/nginx/openvibe.vision.conf` (TLS, www → apex, `/auth/me` on the API zone, a forms zone for `/ask`, `/ocr`,++`/compare` and `/ui-check`, `/internal` and `/metrics` 404). Not deployed by this change.++++## License++++AGPL-3.0-only. See [LICENSE](LICENSE).nnew file mode 100644@@@ -0,0 +1,50 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.vision, openvibe.design, 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.Vision holds++++- Saved answers: the question, the cleaned answer text, the engine/model/provider/route that answered, the reason,++ the cost, the latency, the image sha256, the media type and the byte count. Never the image bytes.++- Compare samples: the same model/provenance/cost/latency per engine. Aggregate benchmark rows have no subject.++- UI checks: source (html, url or screenshot), the viewport, the findings and the summary. Never the screenshot.++- A person's Network session tokens only in httpOnly cookies (`vision_at`, `vision_rt`); no token is stored in the++ database, rendered into a page or logged.++- No provider key. AI calls use the service's own Network client-credentials token and OpenVibe.AI's routing.++++## Image inputs and the UI check++++An image URL is fetched only after every resolved address is checked: loopback, link-local, private and++unique-local addresses are refused, redirects are re-checked, the content type must be an image and the size is++bounded. `VISION_ALLOW_PRIVATE_URLS=1` exists for tests and a deliberately isolated deployment; it is off in++production. A UI check URL goes through the same public-address guard, executes no JavaScript and fetches no remote++stylesheet. The screenshot path writes a temporary file for Tesseract and removes it in a finally block.++++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for++`/internal` and `/metrics`. A service token is judged on its own signature, audience and capability grant; an++unknown or forged token is refused, never downgraded to the session.++++## No faces++++Vision never identifies or recognises faces, never names a person, and never describes a person's looks in public AI text. The model prompt++carries the rule and every AI answer is passed through `noLooks`; an answer that names looks is replaced. A report++that either rule was broken is a security report for this repository.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,68 @@++{++ "repository": "OpenVibers/OpenVibe.Vision",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp",++ "port": "5130",++ "domain": "openvibe.vision",++ "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"++ ],++ "capabilities": [++ "vision.ask",++ "vision.ocr",++ "vision.compare",++ "vision.ui_check"++ ],++ "outboundCapabilities": [++ "ai.run.create"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "summary": "OpenVibe.Vision v1 sees with the best model for the job. A read question goes to the local Tesseract reader first; if the local engine cannot answer, OpenVibe.AI's routed vision model does, and every answer names the model, provider, route, cost and latency and says why it was chosen. A compare runs the same image through the local reader and up to three AI model hints side by side. The UI check reads HTML, a public URL or a screenshot locally and returns overflow, cut-off text, low contrast, overlap and layering findings as JSON; Design's Arena can call POST /api/v1/ui-check with a service token holding vision.ui_check. No image bytes are stored, no provider key exists here, and no face recognition is ever used.",++ "works": [++ "Ask a picture: multipart upload, data URL or public image URL; task ask/describe/identify/count/translate/ocr; auto/local/ai modes",++ "Local-first OCR: Tesseract 5 on this host, word boxes and mean confidence, zero cost, an optional AI fallback only after an empty local pass",++ "OpenVibe.AI gateway: first-party service token (ai.run.create, namespace ai.*), OpenAI-compatible image message, AI provenance, quota and cost, no direct provider",++ "Compare: local OCR plus one AI run per model hint, requested vs answered model, measured latency and AI cost, sample rows",++ "UI check: static HTML/CSS pass (overflow, cut-off, contrast, overlap, layering), public URL pass with SSRF guard, screenshot pass through OCR boxes and sharp pixels",++ "No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence",++ "No face recognition: not implemented, not called, stated on the pages",++ "Answers, compare samples and UI checks stored without image bytes; account export/deletion over all three",++ "Network sign-in as OAuth client vision (PKCE S256, vision_at/vision_rt/vision_oauth), truthful readiness, loopback metrics",++ "Server-rendered pages with stable vi- ids/classes, discovery artifacts, deploy unit and nginx vhost, CI"++ ],++ "notYet": [++ "Public Vision Arena leaderboards and public test sets (v2); v1 stores aggregate compare samples and exposes them without subjects",++ "Video and computer-use benchmarks (v2/v3)",++ "OpenCV jobs placed on OpenVibe.Run; v1 uses Tesseract on this host and sharp for pixels",++ "A real browser-rendered UI pass (JavaScript execution, getBoundingClientRect, computed styles); v1 is a documented static pass plus a screenshot pass",++ "Search documents/outbox: v1 has no public records to index",++ "A per-person history page; saved answers are reachable by id for their owner and staff",++ "The capability manifests and grants; the main session registers vision.* and grants them"++ ],++ "deployed": false,++ "features": [++ "ask a picture",++ "local-first OCR",++ "OpenVibe.AI routed answers",++ "model compare with cost and latency",++ "UI check: overflow, cut-off, contrast, overlap, layering",++ "noLooks and no face recognition",++ "account export and deletion",++ "Network sign-in",++ "server-rendered pages",++ "discovery",++ "deploy files"++ ],++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.vision — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.vision.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5130, systemd unit openvibe-vision) 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; private, no-store for++## anything drawn for a signed-in person or a saved answer). Never log request bodies or image bytes here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=vision_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=vision_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=vision_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.vision www.openvibe.vision;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.vision$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.vision;++++ ssl_certificate /etc/letsencrypt/live/openvibe.vision/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.vision/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.vision$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.vision;++++ ssl_certificate /etc/letsencrypt/live/openvibe.vision/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.vision/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.vision.access.log;++ error_log /var/log/nginx/openvibe.vision.error.log;++++ # Images are capped at 8 MB by the app (16 MB as a base64 JSON body) and HTML at 1 MB; leave room for the headers.++ client_max_body_size 16m;++++ 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=vision_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5130;++ }++++ location /auth/ {++ limit_req zone=vision_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5130;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=vision_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5130;++ }++++ # ── Forms: ask, OCR, compare and the UI check ───────────++ location ~ ^/(ask|ocr|compare|ui-check)/?$ {++ limit_req zone=vision_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5130;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5130/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5130/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:5130;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,32 @@++[Unit]++Description=OpenVibe.Vision — see with the best model for the job.++# Network (sign-in, JWKS, AI tokens, account events) and AI should be up first, but Vision degrades honestly++# without them: local OCR and the UI check serve, and AI-dependent routes answer 503 (see /api/ready).++After=network.target postgresql.service pgbouncer.service valkey-server.service openvibe-ai.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service openvibe-ai.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.vision++EnvironmentFile=/etc/openvibe/vision.env++Environment=NODE_ENV=production++Environment=PORT=5130++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.vision/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.Vision's own database is PostgreSQL on the host's data role; the code tree stays read-only.++# Tesseract is read from /usr/bin and its language data from /usr/share/tesseract-ocr (read-only).++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,139 @@++-- phase: expand++-- OpenVibe.Vision v1: ask a picture, compare engines side by side, OCR with the local reader, and the UI check.++-- An answer and a UI check are a person's or a caller's own row, not the network's public record: v1 has no public++-- objects, so there is no Search outbox and no tombstone. No image bytes are ever stored: only a sha256 and the++-- declared media type. A model, provider, route and cost are recorded because every answer must say which model++-- answered and why.++++CREATE TABLE vision_answers (++ id text COLLATE "C" PRIMARY KEY, -- vis_ + 26 Crockford base32 characters (ULID)++ subject text COLLATE "C", -- usr_… for a person; null for a service caller++ requester text COLLATE "C" NOT NULL, -- user:usr_… | service:design | anonymous++ task text COLLATE "C" NOT NULL, -- ask | describe | ocr | compare++ question text,++ mode text COLLATE "C" NOT NULL DEFAULT 'auto', -- auto | local | ai++ source text COLLATE "C" NOT NULL, -- upload | url | data++ source_url text, -- the public URL a caller named, never a secret++ image_sha256 text COLLATE "C" NOT NULL,++ image_mime text COLLATE "C" NOT NULL,++ image_bytes integer NOT NULL,++ text text, -- the noLooks-cleaned answer++ answer jsonb NOT NULL DEFAULT '{}'::jsonb, -- model details, OCR lines, compare rows++ model text COLLATE "C",++ provider text COLLATE "C",++ route text COLLATE "C",++ reason text, -- why this engine/model was chosen++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,++ latency_ms integer NOT NULL DEFAULT 0,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT vision_answers_id CHECK (id ~ '^vis_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_answers_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_answers_requester CHECK (length(requester) BETWEEN 1 AND 160),++ CONSTRAINT vision_answers_task CHECK (task IN ('ask', 'describe', 'ocr', 'compare')),++ CONSTRAINT vision_answers_mode CHECK (mode IN ('auto', 'local', 'ai')),++ CONSTRAINT vision_answers_source CHECK (source IN ('upload', 'url', 'data')),++ CONSTRAINT vision_answers_sha CHECK (image_sha256 ~ '^[0-9a-f]{64}$'),++ CONSTRAINT vision_answers_mime CHECK (image_mime ~ '^image/[a-z0-9.+-]+$'),++ CONSTRAINT vision_answers_bytes CHECK (image_bytes BETWEEN 1 AND 20971520),++ CONSTRAINT vision_answers_question CHECK (question IS NULL OR length(question) <= 2000),++ CONSTRAINT vision_answers_text CHECK (text IS NULL OR length(text) <= 20000),++ CONSTRAINT vision_answers_cost CHECK (cost_usd >= 0),++ CONSTRAINT vision_answers_latency CHECK (latency_ms >= 0)++);++CREATE INDEX vision_answers_by_subject ON vision_answers (subject, created_at DESC);++CREATE INDEX vision_answers_by_requester ON vision_answers (requester, created_at DESC);++CREATE INDEX vision_answers_by_hash ON vision_answers (image_sha256, created_at DESC);++++-- One row per engine in a side-by-side compare: local Tesseract and/or one AI run per requested model. The++-- requested model is what the caller asked for; model/provider are what OpenVibe.AI's provenance says answered,++-- so a fallback is never mislabelled. These rows feed GET /api/v1/benchmarks (aggregate, no subjects).++CREATE TABLE vision_compare_samples (++ id text COLLATE "C" PRIMARY KEY, -- vcm_ + ULID++ answer_id text COLLATE "C" REFERENCES vision_answers (id) ON DELETE CASCADE,++ subject text COLLATE "C",++ task text COLLATE "C" NOT NULL DEFAULT 'ask',++ engine text COLLATE "C" NOT NULL, -- local | ai++ requested_model text COLLATE "C",++ model text COLLATE "C",++ provider text COLLATE "C",++ route text COLLATE "C",++ ok boolean NOT NULL DEFAULT false,++ text text,++ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,++ latency_ms integer NOT NULL DEFAULT 0,++ error_code text COLLATE "C",++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT vision_compare_samples_id CHECK (id ~ '^vcm_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_compare_samples_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_compare_samples_task CHECK (task IN ('ask', 'describe', 'ocr', 'translate', 'identify', 'count')),++ CONSTRAINT vision_compare_samples_engine CHECK (engine IN ('local', 'ai')),++ CONSTRAINT vision_compare_samples_cost CHECK (cost_usd >= 0),++ CONSTRAINT vision_compare_samples_latency CHECK (latency_ms >= 0)++);++CREATE INDEX vision_compare_samples_recent ON vision_compare_samples (created_at DESC);++CREATE INDEX vision_compare_samples_by_model ON vision_compare_samples (provider, model, created_at DESC);++CREATE INDEX vision_compare_samples_by_subject ON vision_compare_samples (subject, created_at DESC);++++-- The UI check: a static layout pass over HTML/URL or a screenshot pass through OCR boxes and pixel sampling.++-- Findings are data (kind, severity, selector, detail, evidence); no screenshot is stored.++CREATE TABLE vision_ui_checks (++ id text COLLATE "C" PRIMARY KEY, -- vuc_ + ULID++ subject text COLLATE "C",++ requester text COLLATE "C" NOT NULL,++ source text COLLATE "C" NOT NULL, -- html | url | screenshot++ url text,++ image_sha256 text COLLATE "C",++ viewport_w integer NOT NULL DEFAULT 1280,++ viewport_h integer NOT NULL DEFAULT 800,++ engine text COLLATE "C" NOT NULL, -- static-html | screenshot++ findings jsonb NOT NULL DEFAULT '[]'::jsonb,++ summary jsonb NOT NULL DEFAULT '{}'::jsonb,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT vision_ui_checks_id CHECK (id ~ '^vuc_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_ui_checks_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT vision_ui_checks_source CHECK (source IN ('html', 'url', 'screenshot')),++ CONSTRAINT vision_ui_checks_engine CHECK (engine IN ('static-html', 'screenshot')),++ CONSTRAINT vision_ui_checks_viewport CHECK (viewport_w BETWEEN 160 AND 10000 AND viewport_h BETWEEN 160 AND 10000),++ CONSTRAINT vision_ui_checks_sha CHECK (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$'),++ CONSTRAINT vision_ui_checks_url CHECK (url IS NULL OR url ~ '^https?://')++);++CREATE INDEX vision_ui_checks_by_subject ON vision_ui_checks (subject, created_at DESC);++++-- 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,1970 @@++{++ "name": "openvibe-vision",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-vision",++ "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",++ "multer": "^2.5.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",++ "sharp": "^0.33.5"++ },++ "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/@emnapi/runtime": {++ "version": "1.11.3",++ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",++ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",++ "license": "MIT",++ "optional": true,++ "dependencies": {++ "tslib": "^2.4.0"++ }++ },++ "node_modules/@img/sharp-darwin-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",++ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",++ "cpu": [++ "arm64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-darwin-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",++ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "darwin"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-darwin-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",++ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",++ "cpu": [++ "arm64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-darwin-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",++ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",++ "cpu": [++ "x64"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "darwin"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",++ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",++ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-s390x": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",++ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linux-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",++ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",++ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-libvips-linuxmusl-x64": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",++ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "linux"++ ],++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-linux-arm": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",++ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",++ "cpu": [++ "arm"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm": "1.0.5"++ }++ },++ "node_modules/@img/sharp-linux-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",++ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-s390x": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",++ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",++ "cpu": [++ "s390x"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-s390x": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linux-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",++ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "glibc"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linux-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-arm64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",++ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",++ "cpu": [++ "arm64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-linuxmusl-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",++ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",++ "cpu": [++ "x64"++ ],++ "libc": [++ "musl"++ ],++ "license": "Apache-2.0",++ "optional": true,++ "os": [++ "linux"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"++ }++ },++ "node_modules/@img/sharp-wasm32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",++ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",++ "cpu": [++ "wasm32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",++ "optional": true,++ "dependencies": {++ "@emnapi/runtime": "^1.2.0"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-ia32": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",++ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",++ "cpu": [++ "ia32"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "node_modules/@img/sharp-win32-x64": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",++ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",++ "cpu": [++ "x64"++ ],++ "license": "Apache-2.0 AND LGPL-3.0-or-later",++ "optional": true,++ "os": [++ "win32"++ ],++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ }++ },++ "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/append-field": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",++ "license": "MIT"++ },++ "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/busboy": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",++ "dependencies": {++ "streamsearch": "^1.1.0"++ },++ "engines": {++ "node": ">=10.16.0"++ }++ },++ "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/color": {++ "version": "4.2.3",++ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",++ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",++ "license": "MIT",++ "dependencies": {++ "color-convert": "^2.0.1",++ "color-string": "^1.9.0"++ },++ "engines": {++ "node": ">=12.5.0"++ }++ },++ "node_modules/color-convert": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",++ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",++ "license": "MIT",++ "dependencies": {++ "color-name": "~1.1.4"++ },++ "engines": {++ "node": ">=7.0.0"++ }++ },++ "node_modules/color-name": {++ "version": "1.1.4",++ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",++ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",++ "license": "MIT"++ },++ "node_modules/color-string": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",++ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",++ "license": "MIT",++ "dependencies": {++ "color-name": "^1.0.0",++ "simple-swizzle": "^0.2.2"++ }++ },++ "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/detect-libc": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=8"++ }++ },++ "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/is-arrayish": {++ "version": "0.3.4",++ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",++ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",++ "license": "MIT"++ },++ "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/multer": {++ "version": "2.5.0",++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",++ "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",++ "license": "MIT",++ "dependencies": {++ "append-field": "^1.0.0",++ "busboy": "^1.6.0",++ "type-is": "^1.6.18"++ },++ "engines": {++ "node": ">= 10.16.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "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/sharp": {++ "version": "0.33.5",++ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",++ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",++ "hasInstallScript": true,++ "license": "Apache-2.0",++ "dependencies": {++ "color": "^4.2.3",++ "detect-libc": "^2.0.3",++ "semver": "^7.6.3"++ },++ "engines": {++ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"++ },++ "funding": {++ "url": "https://opencollective.com/libvips"++ },++ "optionalDependencies": {++ "@img/sharp-darwin-arm64": "0.33.5",++ "@img/sharp-darwin-x64": "0.33.5",++ "@img/sharp-libvips-darwin-arm64": "1.0.4",++ "@img/sharp-libvips-darwin-x64": "1.0.4",++ "@img/sharp-libvips-linux-arm": "1.0.5",++ "@img/sharp-libvips-linux-arm64": "1.0.4",++ "@img/sharp-libvips-linux-s390x": "1.0.4",++ "@img/sharp-libvips-linux-x64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",++ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",++ "@img/sharp-linux-arm": "0.33.5",++ "@img/sharp-linux-arm64": "0.33.5",++ "@img/sharp-linux-s390x": "0.33.5",++ "@img/sharp-linux-x64": "0.33.5",++ "@img/sharp-linuxmusl-arm64": "0.33.5",++ "@img/sharp-linuxmusl-x64": "0.33.5",++ "@img/sharp-wasm32": "0.33.5",++ "@img/sharp-win32-ia32": "0.33.5",++ "@img/sharp-win32-x64": "0.33.5"++ }++ },++ "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/simple-swizzle": {++ "version": "0.2.4",++ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",++ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",++ "license": "MIT",++ "dependencies": {++ "is-arrayish": "^0.3.1"++ }++ },++ "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/streamsearch": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",++ "engines": {++ "node": ">=10.0.0"++ }++ },++ "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/tslib": {++ "version": "2.8.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",++ "license": "0BSD",++ "optional": true++ },++ "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,39 @@++{++ "name": "openvibe-vision",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Vision — see with the best model for the job.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "proof": "node scripts/proof.js",++ "test": "node test/run.js",++ "test:pg": "VISION_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",++ "multer": "^2.5.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",++ "sharp": "^0.33.5"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,104 @@++/* OpenVibe.Vision: 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. Results show which engine answered, its cost and its time. */++*, *::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; }++.vi { display: block; }++.vi a { color: var(--room-strong); }++.vi .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.vi-narrow { max-width: 620px; }++.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }++.vi h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.vi-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.vi-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }++.vi-page-h h1, .vi-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; }++.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.vi-section { margin-top: clamp(28px, 4vw, 44px); }++.vi-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.vi-count { color: var(--text-muted, #7386a3); font-size: .86rem; }++.vi-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }++.vi-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }++.vi-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; }++.vi-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }++.vi-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }++.vi-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }++.vi-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.vi-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); }++.vi-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }++.vi-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }++.vi-empty p { margin: 0; line-height: 1.5; }++.vi-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++++++.vi-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.vi-form { display: grid; gap: 14px; }++.vi-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }++.vi-field input, .vi-field select, .vi-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; }++.vi-field input:focus, .vi-field select:focus, .vi-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }++.vi-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }++.vi-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.vi-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.vi-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }++.vi-eps { display: grid; gap: 10px; margin: 12px 0 0; }++.vi-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.vi-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }++.vi-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++++.vi-note.is-warm { background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); border-color: color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent); }++.vi-btn[disabled] { opacity: .5; cursor: not-allowed; }++.vi-field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }++.vi-field input[type=file] { padding: 8px; }++.vi-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 22px; align-items: start; }++.vi-try { padding: 20px; border-radius: 20px; background: color-mix(in srgb, var(--room-accent) 7%, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); }++++/* The aperture: two engines racing on one image */++.vi-race { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 260; margin: 0 auto; color: var(--room-accent); }++.vi-race-img { position: absolute; inset: 0; width: 100%; height: 100%; }++html.ov-motion .vi-race-img path { stroke-dasharray: 420; animation: vi-draw 1.8s cubic-bezier(.2,.7,.2,1) both; }++@keyframes vi-draw { from { stroke-dashoffset: 420; } }++.vi-race-box { position: absolute; display: grid; gap: 2px; padding: 8px 12px; border-radius: 12px; border: 2px solid currentColor; background: color-mix(in srgb, var(--bg-card, #0f1729) 88%, transparent); color: var(--text-primary, #e6edf7); font-size: .82rem; }++.vi-race-box b { color: var(--room-strong); }++.vi-race-box small { font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }++.vi-race-a { left: 6%; top: 12%; border-color: color-mix(in srgb, var(--room-accent) 55%, transparent); }++.vi-race-b { right: 6%; bottom: 14%; border-color: var(--room-accent); }++html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) both; } html.ov-motion .vi-race-b { animation-delay: .35s; }++@keyframes vi-pop { from { opacity: 0; scale: .85; } }++.vi-race-tag { position: absolute; left: 50%; bottom: -4px; translate: -50% 0; padding: 3px 10px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); white-space: nowrap; }++++/* Scenes' demos */++.vi-demo-receipt { display: grid; gap: 4px; width: min(100%, 300px); margin: 0 auto; padding: 18px 20px; border-radius: 6px; background: #fbfaf6; color: #222; font: 500 .9rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: 0 12px 30px rgba(0, 0, 0, .25); }++.vi-demo-receipt strong { margin-top: 6px; padding-top: 6px; border-top: 1px dashed #999; font-size: 1.05rem; }++.vi-demo-receipt small { color: #6b6b6b; font-size: .74rem; }++.vi-demo-kicker { justify-self: start; padding: 2px 8px; border-radius: 6px; font: 800 .68rem/1.4 Inter, sans-serif; letter-spacing: .08em; color: #fff; background: #6b4ab0; }++.vi-demo-table, .vi-table { width: 100%; border-collapse: collapse; font-size: .88rem; font-variant-numeric: tabular-nums; }++.vi-demo-table caption, .vi-table caption { text-align: left; padding-bottom: 8px; color: var(--text-muted, #7386a3); font-size: .8rem; }++.vi-demo-table th, .vi-demo-table td, .vi-table th, .vi-table td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; overflow-wrap: anywhere; }++.vi-demo-table th, .vi-table th { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.vi-demo-table { background: var(--bg-card, #0f1729); border-radius: 14px; overflow: hidden; border: 1px solid var(--border, #1f2d47); }++.vi-table-wrap { overflow-x: auto; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }++.vi-demo-check { display: grid; gap: 10px; }++.vi-demo-page { position: relative; height: 120px; overflow: hidden; border-radius: 12px; border: 2px dashed color-mix(in srgb, var(--error, #ef4444) 55%, transparent); background: var(--bg-card, #0f1729); }++.vi-demo-overflow { position: absolute; left: 12px; top: 14px; width: 140%; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--room-accent) 22%, transparent); font-size: .8rem; }++.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: #8a8f98; }++.vi-demo-check ul, .vi-warnings { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }++.vi-demo-check li, .vi-finding { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .86rem; }++.vi-demo-check li.is-bad b, .vi-finding b { color: var(--error-text, #f87171); }++.vi-demo-check li span, .vi-finding span { color: var(--text-secondary, #96a7c2); }++++/* Results */++.vi-result, .vi-answer-card, .vi-check { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }++.vi-question { margin: 0; font-weight: 800; font-size: 1.05rem; }++.vi-answer-text { margin: 0; font-size: 1.05rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }++.vi-ocr-text, .vi-sample-text, .vi-code { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .86rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; }++.vi-models { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }++.vi-models li { padding: 4px 10px; border-radius: 999px; font-size: .8rem; font-weight: 700; background: color-mix(in srgb, var(--room-accent) 14%, transparent); color: var(--room-strong); }++.vi-warnings li { padding: 8px 12px; border-radius: 10px; font-size: .86rem; background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent); }++++@media (max-width: 860px) { .vi-two-col { grid-template-columns: minmax(0, 1fr); } }++@media (max-width: 759px) { .sc-room-pic .vi-race-box, .sc-room-pic .vi-race-tag { display: none; } }++@media (prefers-reduced-motion: reduce) { .vi-race-img path, .vi-race-box { animation: none !important; } }++html[data-ov-motion=reduced] .vi-race-img path, html[data-ov-motion=reduced] .vi-race-box { animation: none !important; }nnew file mode 100644@@@ -0,0 +1 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OpenVibe.Vision"><path d="M4 32c6-10 15-16 28-16s22 6 28 16c-6 10-15 16-28 16S10 42 4 32Z" fill="none" stroke="#7444B4" stroke-width="3"/><circle cx="32" cy="32" r="9" fill="#7444B4"/><circle cx="35" cy="29" r="3" fill="#fff"/></svg>nnew file mode 100644@@@ -0,0 +1 @@++/* OpenVibe.Vision enhancement layer (main session). The v1 forms and results are complete without JavaScript. */nnew file mode 100644@@@ -0,0 +1,57 @@++#!/usr/bin/env node++'use strict';++++/**++ * The v1 proof, offline: render a receipt, read it with the local Tesseract engine, and check a page with a++ * planted overflow and low contrast. No database, no network, no model. A model call is deliberately not made:++ * the first proof is that the cheap local path and the UI check produce a usable artifact on their own.++ *++ * node scripts/proof.js human-readable++ * node scripts/proof.js --json one JSON document++ */++const sharp = require('sharp');++const { load } = require('../server/config');++const { createOcr } = require('../server/vision/ocr');++const { checkHtml } = require('../server/vision/ui-check');++++function receipt() {++ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360">++<rect width="100%" height="100%" fill="#ffffff"/>++<text x="28" y="62" font-family="DejaVu Sans Mono" font-size="34" fill="#111111">OPENVIBE MARKET</text>++<text x="28" y="120" font-family="DejaVu Sans Mono" font-size="26" fill="#111111">2026-10-28 14:02</text>++<text x="28" y="176" font-family="DejaVu Sans Mono" font-size="28" fill="#111111">Oat milk 3.40</text>++<text x="28" y="222" font-family="DejaVu Sans Mono" font-size="28" fill="#111111">Bread 2.10</text>++<text x="28" y="294" font-family="DejaVu Sans Mono" font-size="34" fill="#111111">TOTAL 5.50</text>++</svg>`;++ return sharp(Buffer.from(svg)).png().toBuffer();++}++++const PLANTED = `<!doctype html><html><head><style>++body{background:#fff;color:#9a9a9a;font-size:16px}++.wide{width:1400px;height:40px;background:#eee}++.cut{width:120px;overflow:hidden;white-space:nowrap}++</style></head><body><p>Low contrast words here</p><div class="wide">wide</div><div class="cut">This line will be cut off</div></body></html>`;++++(async () => {++ const config = load({ NODE_ENV: 'development', VISION_TESSERACT: process.env.VISION_TESSERACT || 'tesseract' });++ const ocr = createOcr({ config });++ const image = await receipt();++ const read = await ocr.recognize(image);++ const ui = checkHtml(PLANTED, { viewport: { w: 390, h: 844 } });++ const out = {++ okay: Boolean(read.ok && /TOTAL\s+5\.50/.test(read.text || '')) && ui.counts.overflow >= 1 && ui.counts.contrast >= 1 && ui.counts.cut_off >= 1,++ ocr: { engine: 'tesseract', text: read.text || null, confidence: read.confidence == null ? null : read.confidence, ms: read.ms == null ? null : read.ms, cost_usd: 0, error: read.ok ? null : read.code },++ ui: { engine: ui.engine, viewport: ui.viewport, counts: ui.counts, summary: ui.summary, findings: ui.findings },++ model_calls: 0,++ };++ if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);++ else {++ console.log('OpenVibe.Vision v1 proof (offline)');++ console.log(` local OCR: ${read.ok ? 'ok' : read.code} · ${read.ms} ms · confidence ${read.confidence}`);++ console.log(` text: ${JSON.stringify(read.text || '')}`);++ console.log(` UI check: ${ui.summary}`);++ for (const f of ui.findings) console.log(` - ${f.kind} (${f.severity}) ${f.selector}: ${f.detail}`);++ console.log(` model calls: 0`);++ }++ if (!out.okay) process.exitCode = 1;++})().catch((err) => { console.error(err && err.message ? err.message : err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,175 @@++'use strict';++++/**++ * OpenVibe.Vision — Express app factory; server/index.js listens, tests build their own instance with a temp++ * database, a mock Network and a mock OpenVibe.AI.++ *++ * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /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)++ *++ * Every model call goes through OpenVibe.AI; the local OCR engine and the UI check run on this host. No provider++ * key, no paid provider and no image bytes are stored.++ */++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 { createOcr } = require('./vision/ocr');++const { createAi } = require('./vision/ai');++const { createVision } = require('./vision/service');++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, AI and public URLs), 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 store = createStore(s);++ const ocr = opts.ocr || createOcr({ config, log });++ const ai = opts.ai || createAi({ config, fetchImpl, log });++ const vision = createVision({ config, s, ocr, ai, store, log });++++ // 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: Vision cannot answer account events'); });++++ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, accountData, accountSend, log, fetchImpl };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'vision', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vision', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'vision', 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:', 'blob:', '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-vision', name: 'OpenVibe.Vision', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, valkey, ai, ocr });++ 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: 'vision', service: 'vision', host: 'openvibe.vision', name: 'OpenVibe.Vision', 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.VISION_API_RATE_LIMIT_PER_MIN) || 120, 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.VISION_RATE_LIMIT_PER_MIN) || 180, 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.Vision]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's Bearer token (the API).++ *++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves++ * this module: token.no_key answers 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Vision] 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.Vision] 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.Vision] 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 `vision`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256++ * (?silent=1 adds prompt=none; ?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest++ *++ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives++ * in an httpOnly cookie (vision_at) no script can read; the refresh token (vision_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 = 'vision_at';++const REFRESH_COOKIE = 'vision_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'vision_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++function ownOrigins(config) {++ const out = new Set();++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }++ }++ return out;++}++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next, config) {++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null;++ try { u = new URL(next); } catch { return '/'; }++ if (!ownOrigins(config).has(u.origin)) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ /**++ * A Vision session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ res.set('Clear-Site-Data', '"cache"');++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Vision] 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.Vision?</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,106 @@++'use strict';++++/**++ * OpenVibe.Vision configuration. Every value comes from the environment (production: /etc/openvibe/vision.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, 5130);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.vision' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'vision',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.VISION_DRILL === '1' || env.VISION_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.VISION_LIMITS_MINUTE, 60)),++ hour: Math.max(1, int(env.VISION_LIMITS_HOUR, 600)),++ },++++ // 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.VISION_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:vision:' },++++ // 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.VISION_AUDIENCE || 'openvibe.vision',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'vision',++ 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.VISION_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.VISION_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.VISION_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.VISION_EVENTS_INTERVAL_MS, 5000)),++ scope: env.VISION_EVENTS_SCOPE || 'events.event.publish',++ },++++ // OpenVibe.AI: the model gateway. Vision never talks to a model provider directly and never holds a++ // provider key. Calls are first-party service-token runs (ai.run.create, namespace ai.*), so AI's own++ // routing, quotas, cache, provenance and cost accounting apply. With no client secret AI is off and the++ // local engines alone answer.++ ai: {++ url: trim(env.OV_AI_URL || env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),++ audience: env.VISION_AI_AUDIENCE || 'openvibe.ai',++ scope: env.VISION_AI_SCOPE || 'ai.run.create',++ namespace: env.VISION_AI_NAMESPACE || 'ai.*',++ models: String(env.VISION_AI_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean).slice(0, 6),++ timeoutMs: Math.max(5000, int(env.VISION_AI_TIMEOUT_MS, 45000)),++ waitMs: Math.max(1000, int(env.VISION_AI_WAIT_MS, 30000)),++ maxTokens: Math.min(2000, Math.max(64, int(env.VISION_AI_MAX_TOKENS, 700))),++ temperature: Math.min(1, Math.max(0, Number(env.VISION_AI_TEMPERATURE || 0.1))),++ },++++ // The local OCR engine (Tesseract 5). OpenCV is the classical first step the plan names; Tesseract is the++ // local reader v1 ships. A missing binary is an honest 503, never a silent cloud fallback.++ ocr: {++ bin: env.VISION_TESSERACT || 'tesseract',++ lang: env.VISION_OCR_LANG || 'eng',++ timeoutMs: Math.max(2000, int(env.VISION_OCR_TIMEOUT_MS, 20000)),++ },++++ // Image inputs: upload (multipart), a data: URL, or a public https URL fetched with the private-address++ // guard. No image bytes are stored in the database or written to a page.++ images: {++ maxMb: Math.min(20, Math.max(1, int(env.VISION_IMAGE_MAX_MB, 8))),++ timeoutMs: Math.max(1000, int(env.VISION_IMAGE_TIMEOUT_MS, 10000)),++ allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',++ },++++ // The UI check: a static layout pass over HTML or a fetched page (no JavaScript executed), plus a++ // screenshot path through the local OCR boxes and sharp pixel sampling. Nothing leaves this host.++ ui: {++ maxHtmlBytes: Math.min(5 * 1024 * 1024, Math.max(64 * 1024, int(env.VISION_UI_MAX_HTML_KB, 1024) * 1024)),++ timeoutMs: Math.max(1000, int(env.VISION_UI_TIMEOUT_MS, 10000)),++ allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',++ maxFindings: Math.min(500, Math.max(10, int(env.VISION_UI_MAX_FINDINGS, 200))),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Vision's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret: an answer stores the cleaned text, a sha256 of the image and the model++ * provenance, never the image bytes, a token or a provider 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.Vision] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'vision', 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: 'vision-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'vision', 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,252 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Vision's JSON API.++ *++ * GET /ping++ * POST /ask one image and a question: local OCR first, then OpenVibe.AI++ * POST /ocr the local Tesseract reader; `fallback=1` asks AI only when it finds no text++ * POST /compare local OCR vs one or more AI model hints, side by side, with cost and latency++ * POST /ui-check overflow, cut-off, contrast, overlap and layering faults from html, url or image++ * GET /answers/:id one saved answer (its owner, or a service holding vision.ask)++ * GET /ui-checks/:id one saved UI check (its owner, or a service holding vision.ui.check)++ * GET /models the AI route's offered provider:model hints (no subject data)++ * GET /benchmarks aggregate compare samples: model, task, samples, mean latency and cost++ *++ * An image is a multipart `image` field, a JSON `image` data URL, or a JSON `image_url` public URL. Errors are++ * application/problem+json with a code. A person is the vision_at session cookie or a Network user token as a++ * Bearer; a service token needs exactly the capability named on the route.++ */++const express = require('express');++const multer = require('multer');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { loadImage } = require('../vision/images');++const { VisionError } = require('../vision/service');++++function createApi(ctx) {++ const { config, s, principal, store, vision, ai, ocr, fetchImpl } = ctx;++ const r = asyncRouter();++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });++++ // A data: URL is base64, so an 8 MB image is about 10.7 MB of JSON. Keep the parser above that, below the++ // multipart cap. The decoded bytes are checked again by loadImage.++ r.use(express.json({ limit: `${Math.ceil(config.images.maxMb * 1.5) + 2}mb` }));++ 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 service caller is bounded too.++ 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) => {++ const p = req.principal;++ const who = p && p.kind === 'user' ? `user:${p.subject}` : p && p.kind === 'service' ? `svc:${p.sub}` : null;++ if (who) {++ 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, 'vision.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, 'vision.invalid', detail);++ const isPerson = (req) => req.principal && req.principal.kind === 'user';++ const isMachine = (req) => req.principal && req.principal.kind === 'service';++++ const attachUpload = (req, res, next) => upload.single('image')(req, res, (err) => {++ if (!err) return next();++ if (err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'vision.image_too_large', `the image is larger than ${config.images.maxMb} MB`);++ if (err.code === 'LIMIT_UNEXPECTED_FILE') return invalid(res, 'send one image in the field named image');++ return next(err);++ });++++ function bodyOf(req) { return req.body && typeof req.body === 'object' ? req.body : {}; }++ async function imageOf(req) {++ const b = bodyOf(req);++ const got = await loadImage({++ file: req.file && req.file.buffer,++ imageUrl: b.image_url,++ image: b.image,++ config,++ fetchImpl,++ });++ return got;++ }++ function viewportOf(req) {++ const b = bodyOf(req);++ let v = b.viewport;++ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { v = null; } }++ return {++ w: Math.max(160, Math.min(10000, Number((v && v.w) || b.viewport_w || req.query.width) || 1280)),++ h: Math.max(160, Math.min(10000, Number((v && v.h) || b.viewport_h || req.query.height) || 800)),++ };++ }++ async function run(res, fn, status = 200) {++ try {++ const out = await fn();++ return res.status(status).json(out);++ } catch (err) {++ if (err instanceof VisionError) {++ if (err.extra && Object.keys(err.extra).length) return res.status(err.status).json({ type: `https://openvibe.vision/problems/${err.code}`, title: 'Request refused', status: err.status, code: err.code, detail: err.detail, ...err.extra });++ return problem(res, err.status, err.code, err.detail);++ }++ if (err && err.code && /^image\./.test(err.code)) {++ const status2 = err.code === 'image.too_large' ? 413 : err.code === 'image.private_url' ? 422 : 400;++ return problem(res, status2, `vision.${err.code.replace('.', '_')}`, err.message);++ }++ throw err;++ }++ }++++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));++++ // ── OCR: local first, anonymous local calls allowed ─────++ r.post('/ocr', attachUpload, principal.optional('vision.ocr', { allowAnonymous: true }), async (req, res) => {++ const b = bodyOf(req);++ const fallback = b.fallback === true || b.fallback === '1' || b.fallback === 'true';++ if (req.principal.kind === 'anonymous' && fallback) return problem(res, 401, 'token.required', 'AI fallback needs a signed-in person or a service token holding vision.ocr');++ return run(res, async () => {++ const image = await imageOf(req);++ return await vision.ocr({++ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,++ image, lang: b.lang, fallback, model: b.model,++ });++ }, 201);++ });++++ // ── Ask: local OCR answers a read question, else AI ─────++ // /ask is the product's name; /describe is the operation name in the plan's API list. Same handler.++ const askHandler = (defaultTask) => async (req, res) => {++ const b = bodyOf(req);++ const task = b.task || defaultTask;++ const mode = b.mode || 'auto';++ const question = String(b.question || b.q || '').trim();++ const readish = vision.isReadTask(task, question);++ if (req.principal.kind === 'anonymous' && mode !== 'local' && !readish) {++ return problem(res, 401, 'token.required', 'Ask needs a signed-in person or a service token holding vision.ask; anonymous callers can use mode=local or a read question that the local OCR engine answers.');++ }++ return run(res, async () => {++ const image = await imageOf(req);++ const out = await vision.ask({++ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,++ image, task, question, mode: req.principal.kind === 'anonymous' ? 'local' : mode, lang: b.lang, model: b.model,++ });++ return { answer: out };++ }, 201);++ };++ r.post('/ask', attachUpload, principal.optional('vision.ask', { allowAnonymous: true }), askHandler('ask'));++ r.post('/describe', attachUpload, principal.optional('vision.ask', { allowAnonymous: true }), askHandler('describe'));++++ // ── Compare: local + AI hints, side by side ────────────++ r.post('/compare', principal.optional('vision.compare'), attachUpload, async (req, res) => {++ const b = bodyOf(req);++ const models = Array.isArray(b.models) ? b.models : (typeof b.models === 'string' && b.models.trim() ? b.models.split(',').map((x) => x.trim()) : []);++ return run(res, async () => {++ const image = await imageOf(req);++ const out = await vision.compare({++ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,++ image, task: b.task || 'ask', question: b.question || b.q || '', models, lang: b.lang,++ });++ return { answer: out, compare: out.compare };++ }, 201);++ });++++ // ── UI check: HTML, URL or screenshot; local only ──────++ r.post('/ui-check', attachUpload, principal.optional('vision.ui.check', { allowAnonymous: true }), async (req, res) => {++ const b = bodyOf(req);++ const html = typeof b.html === 'string' ? b.html : null;++ const url = typeof b.url === 'string' ? b.url.trim() : null;++ if (!html && !url && !req.file && !b.image && !b.image_url) return invalid(res, 'send html, url, an image file, an image data URL, or image_url');++ if (html && Buffer.byteLength(html, 'utf8') > config.ui.maxHtmlBytes) return problem(res, 413, 'vision.ui_too_large', 'that HTML is larger than the UI check limit');++ return run(res, async () => {++ const viewport = viewportOf(req);++ let out; let image = null;++ if (req.file || b.image || b.image_url) {++ image = await imageOf(req);++ out = await require('../vision/ui-check').check({ imageBuffer: image.buffer, viewport, ocr, maxFindings: config.ui.maxFindings });++ } else if (html) {++ out = await require('../vision/ui-check').check({ html, viewport, maxFindings: config.ui.maxFindings });++ } else {++ out = await require('../vision/ui-check').check({ url, viewport, config, fetchImpl, maxFindings: config.ui.maxFindings });++ }++ const saved = await store.saveUiCheck({++ subject: isPerson(req) ? req.principal.subject : null,++ requester: req.principal.requester || 'anonymous',++ source: out.source, url: out.url || null, imageSha256: image ? image.sha256 : null,++ viewport: out.viewport, engine: out.engine, findings: out.findings, summary: out,++ });++ return { check: { ...saved, findings: out.findings, counts: out.counts, severity: out.severity, warnings: out.warnings, summary_text: out.summary } };++ }, 201);++ });++++ // ── Reads ──────────────────────────────────────────────++ r.get('/answers/:id', principal.optional('vision.ask', { allowAnonymous: true }), async (req, res) => {++ const row = await store.answerById(String(req.params.id || ''));++ if (!row) return problem(res, 404, 'vision.not_found', 'No such answer.');++ const owner = await store.answerSubject(row.id);++ const mine = isPerson(req) && Boolean(owner) && req.principal.subject === owner;++ const service = isMachine(req);++ if (!mine && !service) return problem(res, 404, 'vision.not_found', 'No such answer.');++ res.set('Cache-Control', 'private, no-store');++ return res.json(row);++ });++++ r.get('/ui-checks/:id', principal.optional('vision.ui.check', { allowAnonymous: true }), async (req, res) => {++ const row = await store.uiCheckById(String(req.params.id || ''));++ if (!row) return problem(res, 404, 'vision.not_found', 'No such UI check.');++ const owner = await store.uiCheckSubject(row.id);++ const mine = isPerson(req) && Boolean(owner) && req.principal.subject === owner;++ const service = isMachine(req);++ if (!mine && !service) return problem(res, 404, 'vision.not_found', 'No such UI check.');++ res.set('Cache-Control', 'private, no-store');++ return res.json(row);++ });++++ // ── Model hints and benchmarks (no subject data) ───────++ let modelCache = null;++ r.get('/models', async (_req, res) => {++ if (!modelCache || Date.now() - modelCache.at > 5 * 60_000) {++ const offered = ai.enabled ? await ai.offeredModels().catch(() => []) : [];++ modelCache = { at: Date.now(), offered };++ }++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({++ ai: ai.enabled,++ configured: config.ai.models,++ offered: modelCache.offered,++ local: ['tesseract'],++ note: 'provider:model hints; OpenVibe.AI\'s router is the authority and its provenance is what an answer reports',++ });++ });++++ r.get('/benchmarks', async (req, res) => {++ const task = typeof req.query.task === 'string' && req.query.task ? req.query.task : null;++ const limit = req.query.limit == null ? 50 : Number.parseInt(req.query.limit, 10);++ if (!Number.isFinite(limit) || limit < 1 || limit > 100) return invalid(res, 'limit must be from 1 to 100');++ if (task && !['ask', 'describe', 'ocr', 'translate', 'identify', 'count'].includes(task)) return invalid(res, 'task must be a compare task');++ const rows = await store.benchmarks({ task, limit });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ count: rows.length, benchmarks: rows });++ });++++ // multer's own errors: the route-level handler above turns the common ones into problem+json.++ // eslint-disable-next-line no-unused-vars++ r.use((err, req, res, next) => {++ if (err && err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'vision.image_too_large', `the image is larger than ${config.images.maxMb} MB`);++ return next(err);++ });++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.vision: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The public pages are++ * for search engines and AI crawlers; sign-in, the API, the internal route, saved answers and saved UI checks are++ * not (a saved answer belongs to the person who asked). v1 has no public objects, so the sitemap has no per-record++ * URLs.++ */++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.Vision';++const DESCRIPTION = 'OpenVibe.Vision sees with the best model for the job: ask a picture, read its text with a local OCR engine first, compare models side by side through OpenVibe.AI, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.';++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/a/', '/ui/'];++++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: 'MultimediaApplication', keywords: 'openvibe vision, image understanding, OCR, tesseract, vision models, UI check, contrast, overflow' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function publicPages() {++ return [++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Vision home', text: 'Ask a picture, compare models, read text with the local OCR engine, and check a page for UI faults.' },++ { path: '/ask', changefreq: 'weekly', priority: 0.9, title: 'Ask a picture', text: 'Upload an image or paste a public URL and ask what it shows.' },++ { path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },++ { path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },++ { path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Vision API', text: 'The JSON API, the capabilities other services need, and what leaves this host.' },++ ];++}++++function createDiscoveryRoutes(ctx) {++ const { config } = 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.vision: the public pages are for search and AI crawlers; sign-in, the API, saved answers and the internal route 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.Vision answers questions about a picture. The cheapest engine first: local Tesseract OCR answers a read question; otherwise OpenVibe.AI\'s routed vision model answers. A compare runs the local reader and AI models on the same image and reports each answer with its model, cost and latency. The UI check flags overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. No face recognition, ever; public AI text never describes people\'s looks.',++ details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare or vision.ui.check.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Vision', url: abs('/') },++ { title: 'Ask a picture', url: abs('/ask') },++ { title: 'Read text from a picture', url: abs('/ocr') },++ { title: 'Compare models', url: abs('/compare') },++ { title: 'The UI check', url: abs('/ui-check') },++ { title: 'The API', url: abs('/developers') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Benchmark results (JSON)', url: abs('/api/v1/benchmarks') },++ { title: 'Model hints (JSON)', url: abs('/api/v1/models') },++ { 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.AI', url: 'https://ai.openvibe.services', note: 'the routing, quotas and provenance every model answer uses' },++ { title: 'OpenVibe.Design', url: 'https://openvibe.design', note: 'the Arena that calls the UI check' },++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, tokens and staff roles' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check and the API.',++ base: site,++ maxBytes: 256 * 1024,++ sections: [++ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },++ { title: 'Rules', pages: [++ { url: '/developers', title: 'No face recognition', text: 'Vision never identifies or recognises faces and never describes a person\'s looks in public AI text.' },++ { url: '/developers', title: 'No stored images', text: 'Answers store a sha256, the media type and the cleaned text; never the image bytes.' },++ ] },++ ],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ 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,210 @@++'use strict';++++/**++ * Vision's server-rendered pages. Everything works without JavaScript: every form is a plain POST and every++ * result is a real page. Public pages are `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in++ * person is `private, no-store`.++ *++ * / the room: ask, compare, OCR and the UI check++ * /ask upload or URL + a question (POST /ask → /a/:id)++ * /ocr local OCR (POST /ocr → /a/:id, or inline for a guest)++ * /compare local OCR vs AI model hints (POST /compare → /a/:id)++ * /ui-check HTML, URL or screenshot (POST /ui-check → /ui/:id, or inline for a guest)++ * /a/:id one saved answer (owner or staff)++ * /ui/:id one saved UI check (owner or staff)++ * /developers the API++ * 404++ */++const express = require('express');++const multer = require('multer');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { sameOrigin } = require('./principal');++const { loadImage } = require('../vision/images');++const { check: uiCheck } = require('../vision/ui-check');++const { VisionError } = require('../vision/service');++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, s, store, vision, ai, ocr, fetchImpl } = ctx;++ const r = asyncRouter();++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });++ r.use(express.urlencoded({ extended: false, limit: '2mb' }));++ 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 notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty"><h1>That came from somewhere else</h1><p class="vi-muted">Use the form on <a href="/">OpenVibe.Vision</a>.</p></div></div>`) }, 403);++ const uploadMiddleware = (req, res, next) => upload.single('image')(req, res, (err) => {++ if (!err) return next();++ if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(views.formError(`The image is larger than ${config.images.maxMb} MB.`)) }, 413);++ return next(err);++ });++ const bodyOf = (req) => (req.body && typeof req.body === 'object' ? req.body : {});++ async function imageOf(req) {++ return await loadImage({ file: req.file && req.file.buffer, imageUrl: bodyOf(req).image_url, image: bodyOf(req).image, config, fetchImpl });++ }++ const viewportOf = (req) => {++ const b = bodyOf(req);++ return { w: Math.max(160, Math.min(10000, Number(b.viewport_w) || 1280)), h: Math.max(160, Math.min(10000, Number(b.viewport_h) || 800)) };++ };++ const formError = (message, status = 400) => ({ message, status });++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: null,++ description: 'Ask a picture, compare the local reader with an AI model, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),++ }));++++ // ── Ask ──────────────────────────────────────────────────++ r.get('/ask', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'Ask a picture',++ description: 'Upload an image or paste a public image URL and ask what it shows: describe, read, translate, identify or count.',++ body: raw(views.ask({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),++ }));++ r.post('/ask', uploadMiddleware, async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Ask a picture', body: raw(views.signIn({ title: 'Ask a picture', text: 'An answer saved to your account needs an OpenVibe sign-in.', next: '/ask' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ try {++ const image = await imageOf(req);++ const out = await vision.ask({++ requester: `user:${req.viewer.subject}`, subject: req.viewer.subject, image,++ task: b.task || 'ask', question: b.question || b.q || '', mode: b.mode || 'auto', lang: b.lang, model: b.model,++ });++ return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);++ } catch (err) {++ return page(req, res, { title: 'Ask a picture', body: raw(views.ask({ signedIn: true, ai: ai.enabled, problem: err instanceof VisionError ? err.detail : 'That request could not be answered.', values: { question: b.question || b.q || '', task: b.task || 'ask', mode: b.mode || 'auto', image_url: b.image_url || '' } })) }, err instanceof VisionError ? err.status : 500);++ }++ });++++ // ── OCR ──────────────────────────────────────────────────++ r.get('/ocr', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'Read text from a picture',++ description: 'Local Tesseract OCR first: read a receipt, a sign or a screenshot on this host, with no model call.',++ body: raw(views.ocr({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),++ }));++ r.post('/ocr', uploadMiddleware, async (req, res) => {++ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ const fallback = b.fallback === '1' || b.fallback === 'true' || b.fallback === 'on';++ if (!signedIn(req) && fallback) return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: false, ai: ai.enabled, problem: 'The AI fallback needs a signed-in account. Local OCR runs without one.' })) }, 401);++ try {++ const image = await imageOf(req);++ const out = await vision.ocr({++ requester: signedIn(req) ? `user:${req.viewer.subject}` : 'anonymous', subject: signedIn(req) ? req.viewer.subject : null,++ image, lang: b.lang, fallback: signedIn(req) && fallback, model: b.model,++ });++ if (signedIn(req)) return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);++ return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: false, ai: ai.enabled, result: out })) });++ } catch (err) {++ return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, problem: err instanceof VisionError ? err.detail : 'That image could not be read.' })) }, err instanceof VisionError ? err.status : 500);++ }++ });++++ // ── Compare ──────────────────────────────────────────────++ r.get('/compare', async (req, res) => {++ let models = [];++ try { models = ai.enabled ? await ai.offeredModels() : []; } catch { models = []; }++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'Compare models side by side',++ description: 'Run the same image through the local OCR reader and through OpenVibe.AI, and compare the answer, cost and latency side by side.',++ body: raw(views.compare({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, models, configured: config.ai.models })),++ });++ });++ r.post('/compare', uploadMiddleware, async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Compare models', body: raw(views.signIn({ title: 'Compare models', text: 'A compare spends an AI run, so it needs an OpenVibe account.', next: '/compare' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ const models = b.models == null ? [] : (Array.isArray(b.models) ? b.models : [b.models]);++ try {++ const image = await imageOf(req);++ const out = await vision.compare({++ requester: `user:${req.viewer.subject}`, subject: req.viewer.subject, image,++ task: b.task || 'ask', question: b.question || b.q || '', models, lang: b.lang,++ });++ return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);++ } catch (err) {++ return page(req, res, { title: 'Compare models', body: raw(views.compare({ signedIn: true, ai: ai.enabled, models: [], configured: config.ai.models, problem: err instanceof VisionError ? err.detail : 'That compare could not run.', values: { question: b.question || '', task: b.task || 'ask', image_url: b.image_url || '' } })) }, err instanceof VisionError ? err.status : 500);++ }++ });++++ // ── UI check ─────────────────────────────────────────────++ r.get('/ui-check', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'Check a page for UI faults',++ description: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. The API Design\'s Arena calls.',++ body: raw(views.uiCheck({ signedIn: Boolean(signedIn(req)) })),++ }));++ r.post('/ui-check', uploadMiddleware, async (req, res) => {++ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const b = bodyOf(req);++ const viewport = viewportOf(req);++ try {++ let out; let image = null; let source = 'html';++ if (req.file || b.image || b.image_url) {++ image = await imageOf(req); source = 'screenshot';++ out = await uiCheck({ imageBuffer: image.buffer, viewport, ocr, maxFindings: config.ui.maxFindings });++ } else if (b.html) {++ if (Buffer.byteLength(String(b.html), 'utf8') > config.ui.maxHtmlBytes) throw new VisionError(413, 'vision.ui_too_large', 'that HTML is larger than the UI check limit');++ out = await uiCheck({ html: String(b.html), viewport, maxFindings: config.ui.maxFindings });++ } else if (b.url) {++ source = 'url';++ out = await uiCheck({ url: String(b.url), viewport, config, fetchImpl, maxFindings: config.ui.maxFindings });++ } else {++ throw new VisionError(400, 'vision.invalid', 'send HTML, a URL or a screenshot');++ }++ const saved = signedIn(req) ? await store.saveUiCheck({++ subject: req.viewer.subject, requester: `user:${req.viewer.subject}`, source: out.source,++ url: out.url || null, imageSha256: image ? image.sha256 : null, viewport: out.viewport,++ engine: out.engine, findings: out.findings, summary: out,++ }) : null;++ if (saved) return res.redirect(303, `/ui/${encodeURIComponent(saved.id)}`);++ return page(req, res, { title: 'Check a page for UI faults', body: raw(views.uiCheck({ signedIn: false, result: out })) });++ } catch (err) {++ return page(req, res, { title: 'Check a page for UI faults', body: raw(views.uiCheck({ signedIn: Boolean(signedIn(req)), problem: err instanceof VisionError ? err.detail : (err && err.code ? err.message : 'That page could not be checked.'), values: { html: b.html || '', url: b.url || '', viewport_w: viewport.w, viewport_h: viewport.h } })) }, err instanceof VisionError ? err.status : 400);++ }++ });++++ // ── One saved answer / UI check ──────────────────────────++ r.get('/a/:id', async (req, res) => {++ const row = await store.answerById(String(req.params.id || ''));++ if (!row) return notFound(req, res);++ const owner = await store.answerSubject(row.id);++ const mine = Boolean(signedIn(req) && owner && req.viewer.subject === owner);++ if (!mine && !staffViewer(req)) return notFound(req, res);++ return page(req, res, { title: row.task === 'ocr' ? 'An OCR answer' : 'An answer', body: raw(views.answer({ a: row, mine })), cache: null });++ });++ r.get('/ui/:id', async (req, res) => {++ const row = await store.uiCheckById(String(req.params.id || ''));++ if (!row) return notFound(req, res);++ const owner = await store.uiCheckSubject(row.id);++ const mine = Boolean(signedIn(req) && owner && req.viewer.subject === owner);++ if (!mine && !staffViewer(req)) return notFound(req, res);++ return page(req, res, { title: 'A UI check', body: raw(views.uiCheckResult({ c: row, mine })), cache: null });++ });++++ // ── Developers ───────────────────────────────────────────++ r.get('/developers', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Vision API',++ description: 'Ask, OCR, compare and the UI check as JSON. Design\'s Arena calls POST /api/v1/ui-check with vision.ui.check.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Vision API', url: `${base()}/developers`, description: 'Ask a picture, OCR, compare and the UI check as JSON.', siteUrl: base() })],++ body: raw(views.developers({ ai: ai.enabled, models: config.ai.models })),++ }));++++ 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,127 @@++'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 (vision_at).++ * { kind: 'service', sub: 'svc:design', cap: [...], project, claims }++ * a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one++ * capability (vision.ui.check for Design's Arena, vision.ask, vision.ocr, vision.compare) and checks the++ * token's grant list with openvibe-contracts capabilities.grants. Unknown capability ids are accepted here++ * until the main session registers the manifests; the main session adds the grants.++ * { 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 spend a signed-in visitor's Vision calls.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++/** Vision's four machine capabilities (the main session registers them; v1 accepts the grant directly). */++const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check']);++++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: 'service', sub: r.claims.sub, requester: requesterOfSub(r.claims.sub),++ cap: Array.isArray(r.claims.cap) ? r.claims.cap : [], 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();++ }++++ const granted = (p, cap) => p && p.kind === 'service' && capabilities.grants(p.cap, cap);++++ /**++ * One route, one capability. `allowUser` also admits a signed-in person (Vision's own pages and the person's++ * calls); `allowAnonymous` admits the local-only routes (OCR and the UI check) so the public demos work.++ */++ function requireCapability(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Vision's capabilities`);++ return function guard(req, res, next) {++ const p = req.principal;++ if (!p || p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at openvibe.vision, or send a Network token holding ${cap}.`, ctx: req.ov });++ if (p.kind === 'user') {++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'vision.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.vision itself.', ctx: req.ov });++ return next();++ }++ return granted(p, cap) ? next() : http.sendProblem(res, 403, 'capability.denied', { detail: `${cap} not granted`, ctx: req.ov });++ };++ }++++ /** A person or a service token holding cap; anonymous only when allowAnonymous is set. */++ function optional(cap, { allowAnonymous = false } = {}) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`optional(${cap}): not one of OpenVibe.Vision's capabilities`);++ return function guard(req, res, next) {++ const p = req.principal;++ if (p && p.kind === 'user') {++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'vision.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.vision itself.', ctx: req.ov });++ return next();++ }++ if (p && p.kind === 'service') return granted(p, cap) ? next() : http.sendProblem(res, 403, 'capability.denied', { detail: `${cap} not granted`, ctx: req.ov });++ if (allowAnonymous) return next();++ return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at openvibe.vision, or send a Network token holding ${cap}.`, ctx: req.ov });++ };++ }++++ return { middleware, resolve, requireCapability, optional, granted, CAPABILITIES };++}++++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,30 @@++'use strict';++++/**++ * Account export and deletion → Vision (ADR-033; openvibe-sdk/account-data). The rows a person owns are their++ * answers, their compare samples and their UI checks. The local OCR text is part of the answer; image bytes are++ * never stored, so there is nothing else to erase. A row written by a service token has no subject and is not a++ * person's account data.++ *++ * The database stores bare usr_ ids in the subject columns; an exported file never includes another person's row.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ // Compare samples before their answer, so the deletion counts them itself rather than through the FK cascade.++ { table: 'vision_compare_samples', subject: 'subject', value: (usr) => usr, file: 'compare.json', erase: 'delete' },++ { table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },++ { table: 'vision_ui_checks', subject: 'subject', value: (usr) => usr, file: 'ui-checks.json', erase: 'delete' },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'vision',++ tables: TABLES,++ note: 'Image bytes are never stored: an answer holds its cleaned text, a sha256 of the image and model provenance.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * Who is staff at OpenVibe.Vision: 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.Vision — process entry. `node server/index.js`++ * Listens on PORT (5130) 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, telemetry closes,++ * 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.Vision', 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.Vision] ${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.Vision] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing++ * key, the OAuth client, OpenVibe.AI and the local OCR binary are optional and say so. Vision serves local OCR++ * and the UI check without them; ask and compare need AI only when they cannot answer locally.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null }) {++ return createReadiness({++ service: 'vision',++ 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 and AI calls cannot complete') },++ { name: 'ai', required: false, check: () => (ai && ai.enabled ? 'AI is configured on demand; a run is only made when a local engine cannot answer' : 'OpenVibe.AI is not configured: local OCR and the UI check still serve') },++ {++ name: 'local_ocr', required: false,++ check: async () => {++ if (!ocr) return 'no local OCR engine';++ const v = await ocr.available();++ return v ? { ok: true, detail: v } : 'tesseract is not installed: OCR and the screenshot pass answer 503';++ },++ },++ ],++ });++}++++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,120 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * This is Today's layout with Vision'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.Vision';++const TAGLINE = 'See with the best model for the job.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Ask a picture', href: '/ask' },++ { label: 'OCR', href: '/ocr' },++ { label: 'Compare', href: '/compare' },++ { label: 'UI check', href: '/ui-check' },++ { 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: 'vision',++ 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: 'vision', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'vision' })}++<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/vision.css')}">++<script src="${ovServe.url('motion.js')}" defer></script>++<script src="${asset('js/vision.js')}" defer></script>++${render(scripts)}++<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>++<main id="main" class="vi ov-room" style="${rooms.style('vision')}">++${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,189 @@++'use strict';++++/**++ * Vision's rows: one answer (ask/describe/OCR/compare), the compare samples that answer produced, and one UI++ * check. Image bytes are never here. A row with a subject is the person's and is exported/erased with their++ * account; a row from a service token has no subject.++ *++ * GET /api/v1/benchmarks aggregates these rows without subjects: per provider/model, task, samples, successful++ * answers, mean latency and mean cost. v1 keeps the measurements; the public Arena leaderboards are v2.++ */++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);++++function wireAnswer(row) {++ if (!row) return null;++ let answer = row.answer;++ if (typeof answer === 'string') { try { answer = JSON.parse(answer); } catch { answer = {}; } }++ return {++ id: row.id,++ task: row.task,++ question: row.question == null ? null : row.question,++ mode: row.mode,++ source: row.source,++ source_url: row.source_url == null ? null : row.source_url,++ image_sha256: row.image_sha256,++ image_mime: row.image_mime,++ image_bytes: Number(row.image_bytes) || 0,++ text: row.text == null ? null : row.text,++ answer: answer && typeof answer === 'object' ? answer : {},++ model: row.model == null ? null : row.model,++ provider: row.provider == null ? null : row.provider,++ route: row.route == null ? null : row.route,++ reason: row.reason == null ? null : row.reason,++ cost_usd: num(row.cost_usd),++ latency_ms: Number(row.latency_ms) || 0,++ created_at: iso(row.created_at),++ };++}++++function wireSample(row) {++ if (!row) return null;++ return {++ engine: row.engine,++ requested_model: row.requested_model == null ? null : row.requested_model,++ model: row.model == null ? null : row.model,++ provider: row.provider == null ? null : row.provider,++ route: row.route == null ? null : row.route,++ ok: Boolean(row.ok),++ text: row.text == null ? null : row.text,++ cost_usd: num(row.cost_usd),++ latency_ms: Number(row.latency_ms) || 0,++ error_code: row.error_code == null ? null : row.error_code,++ };++}++++function wireUiCheck(row) {++ if (!row) return null;++ let findings = row.findings; let summary = row.summary;++ if (typeof findings === 'string') { try { findings = JSON.parse(findings); } catch { findings = []; } }++ if (typeof summary === 'string') { try { summary = JSON.parse(summary); } catch { summary = {}; } }++ return {++ id: row.id,++ source: row.source,++ url: row.url == null ? null : row.url,++ image_sha256: row.image_sha256 == null ? null : row.image_sha256,++ viewport: { w: Number(row.viewport_w) || 1280, h: Number(row.viewport_h) || 800 },++ engine: row.engine,++ findings: Array.isArray(findings) ? findings : [],++ summary: summary && typeof summary === 'object' ? summary : {},++ created_at: iso(row.created_at),++ };++}++++function createStore(s) {++ const db = s.db;++++ async function saveAnswer({ subject = null, requester, task, question = null, mode = 'auto', image, text = null, answer = {}, model = null, provider = null, route = null, reason = null, costUsd = 0, latencyMs = 0 }) {++ const id = s.newId('vis');++ const row = await db.maybe(++ `INSERT INTO vision_answers (id, subject, requester, task, question, mode, source, source_url, image_sha256, image_mime, image_bytes,++ text, answer, model, provider, route, reason, cost_usd, latency_ms, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18, $19, now()) RETURNING *`,++ [id, subject, requester, task, question, mode, image.source, image.sourceUrl || null, image.sha256, image.mime, image.bytes,++ text == null ? null : String(text).slice(0, 20000), JSON.stringify(answer || {}), model, provider, route, reason, Math.max(0, Number(costUsd) || 0), Math.max(0, Math.round(Number(latencyMs) || 0))],++ );++ return wireAnswer(row);++ }++++ async function answerById(id) {++ return wireAnswer(await db.maybe('SELECT * FROM vision_answers WHERE id = $1', [String(id || '')]));++ }++++ /** Ownership is checked from the row, never from a value the API returns. */++ async function answerSubject(id) {++ const row = await db.maybe('SELECT subject FROM vision_answers WHERE id = $1', [String(id || '')]);++ return row ? row.subject : null;++ }++++ async function listAnswers({ subject = null, requester = null, limit = 20 } = {}) {++ const n = Math.min(50, Math.max(1, Number(limit) || 20));++ const args = []; const where = [];++ if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }++ if (requester) { args.push(requester); where.push(`requester = $${args.length}`); }++ args.push(n);++ const rows = await db.many(`SELECT * FROM vision_answers ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT $${args.length}`, args);++ return rows.map(wireAnswer);++ }++++ async function saveCompare({ subject = null, requester, task = 'ask', image, question = null, samples = [] }) {++ return await s.tx(async (t) => {++ const answerId = s.newId('vis');++ const rows = (Array.isArray(samples) ? samples : []).slice(0, 8).map((x) => ({++ engine: x.engine === 'local' ? 'local' : 'ai',++ requested_model: x.requestedModel == null ? null : String(x.requestedModel).slice(0, 120),++ model: x.model == null ? null : String(x.model).slice(0, 120),++ provider: x.provider == null ? null : String(x.provider).slice(0, 80),++ route: x.route == null ? null : String(x.route).slice(0, 80),++ ok: Boolean(x.ok),++ text: x.text == null ? null : String(x.text).slice(0, 20000),++ costUsd: Math.max(0, Number(x.costUsd) || 0),++ latencyMs: Math.max(0, Math.round(Number(x.ms) || 0)),++ errorCode: x.errorCode == null ? null : String(x.errorCode).slice(0, 80),++ }));++ const best = rows.filter((x) => x.ok).sort((a, b) => a.costUsd - b.costUsd || a.latencyMs - b.latencyMs)[0] || null;++ const text = best ? best.text : null;++ const answerRow = await t.maybe(++ `INSERT INTO vision_answers (id, subject, requester, task, question, mode, source, source_url, image_sha256, image_mime, image_bytes,++ text, answer, model, provider, route, reason, cost_usd, latency_ms, created_at)++ VALUES ($1, $2, $3, $4, $5, 'auto', $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15, $16, $17, $18, now()) RETURNING *`,++ [answerId, subject, requester, task, question, image.source, image.sourceUrl || null, image.sha256, image.mime, image.bytes,++ text == null ? null : String(text).slice(0, 20000), JSON.stringify({ samples: rows }), best && best.model, best && best.provider, best && best.route,++ best ? 'cheapest successful engine in this compare' : 'no engine answered', best ? best.costUsd : 0, best ? best.latencyMs : 0],++ );++ for (const x of rows) {++ await t.exec(++ `INSERT INTO vision_compare_samples (id, answer_id, subject, task, engine, requested_model, model, provider, route, ok, text, cost_usd, latency_ms, error_code, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, now())`,++ [s.newId('vcm'), answerId, subject, ['ask', 'describe', 'ocr', 'translate', 'identify', 'count'].includes(task) ? task : 'ask',++ x.engine, x.requested_model, x.model, x.provider, x.route, x.ok, x.text, x.costUsd, x.latencyMs, x.errorCode],++ );++ }++ return { answer: wireAnswer(answerRow), samples: rows.map((x) => ({ engine: x.engine, requested_model: x.requested_model, model: x.model, provider: x.provider, route: x.route, ok: x.ok, text: x.text, cost_usd: x.costUsd, latency_ms: x.latencyMs, error_code: x.errorCode })) };++ });++ }++++ async function saveUiCheck({ subject = null, requester, source, url = null, imageSha256 = null, viewport, engine, findings, summary }) {++ const id = s.newId('vuc');++ const row = await db.maybe(++ `INSERT INTO vision_ui_checks (id, subject, requester, source, url, image_sha256, viewport_w, viewport_h, engine, findings, summary, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, now()) RETURNING *`,++ [id, subject, requester, source, url, imageSha256, Math.max(160, Number(viewport && viewport.w) || 1280), Math.max(160, Number(viewport && viewport.h) || 800), engine, JSON.stringify(findings || []), JSON.stringify(summary || {})],++ );++ return wireUiCheck(row);++ }++++ async function uiCheckById(id) {++ return wireUiCheck(await db.maybe('SELECT * FROM vision_ui_checks WHERE id = $1', [String(id || '')]));++ }++++ async function uiCheckSubject(id) {++ const row = await db.maybe('SELECT subject FROM vision_ui_checks WHERE id = $1', [String(id || '')]);++ return row ? row.subject : null;++ }++++ /** Aggregate only: no subject, no image, no answer text. Enough for the v1 first proof and the v2 Arena. */++ async function benchmarks({ task = null, limit = 50 } = {}) {++ const n = Math.min(100, Math.max(1, Number(limit) || 50));++ const args = []; let where = "ok = true AND model IS NOT NULL";++ if (task) { args.push(String(task)); where += ` AND task = $${args.length}`; }++ args.push(n);++ const rows = await db.many(++ `SELECT task, engine, provider, model, COUNT(*)::int AS samples,++ ROUND(AVG(latency_ms))::int AS avg_latency_ms, ROUND(AVG(cost_usd)::numeric, 6) AS avg_cost_usd,++ ROUND(MIN(cost_usd)::numeric, 6) AS min_cost_usd, ROUND(MAX(latency_ms))::int AS max_latency_ms++ FROM vision_compare_samples WHERE ${where}++ GROUP BY task, engine, provider, model ORDER BY avg_cost_usd ASC, avg_latency_ms ASC LIMIT $${args.length}`,++ args,++ );++ return rows.map((r) => ({++ task: r.task, engine: r.engine, provider: r.provider, model: r.model, samples: Number(r.samples) || 0,++ avg_latency_ms: Number(r.avg_latency_ms) || 0, avg_cost_usd: num(r.avg_cost_usd),++ min_cost_usd: num(r.min_cost_usd), max_latency_ms: Number(r.max_latency_ms) || 0,++ }));++ }++++ return { saveAnswer, answerById, answerSubject, listAnswers, saveCompare, saveUiCheck, uiCheckById, uiCheckSubject, benchmarks };++}++++module.exports = { createStore, wireAnswer, wireSample, wireUiCheck };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and MediaHub):++ * an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. A summary that++ * names looks at all is not repaired word by word — it is replaced by the entry's title, as Meme's plan says.++ * Verbatim quotes are never passed through this: rewriting a quote would falsify the citation.++ */++const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');++++/** The summary to store for an AI-written entry summary. */++function cleanSummary(title, summary) {++ const t = String(title || '').trim() || 'Untitled item';++ 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 };++++/**++ * Public AI text in Vision: an answer that names a person's looks at all is replaced by a neutral sentence,++ * never repaired word by word. The prompt already forbids it; this is the guarantee after the model speaks.++ */++const SAFE_ANSWER = 'I can describe the picture without describing anyone\'s appearance. Ask about the scene, the text, the objects or what is happening.';++++function cleanAnswer(text, { fallback = SAFE_ANSWER } = {}) {++ const s = String(text == null ? '' : text).trim();++ if (!s) return fallback;++ // Live's rule: a sentence about someone's looks goes whole (noLooks can leave "has long blonde hair" behind++ // once it has neutralised the pronoun); the rest still passes noLooks. If nothing look-free remains, the++ // neutral sentence is the answer.++ const kept = s.split(/(?<=[.!?])\s+/).filter((sentence) => !hasLooks(sentence)).join(' ').trim();++ if (!kept) return fallback;++ const cleaned = String(noLooks(kept) || '').trim();++ return cleaned && !hasLooks(cleaned) ? cleaned : fallback;++}++++module.exports = { cleanSummary, cleanAnswer, noLooks, hasLooks, SAFE_ANSWER };nnew file mode 100644@@@ -0,0 +1,261 @@++'use strict';++++/**++ * The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is++ * a plain form or link. public/css/vision.css and public/js/vision.js are one-comment placeholders the main++ * session replaces; the forms and results work without them. Ids and classes start with `vi-`.++ */++const { esc, time } = require('../render/html');++const sc = require('openvibe-shared/showcase');++++const MODEL_LABEL = (a) => (a && a.model ? `${a.provider ? `${a.provider}:` : ''}${a.model}` : 'the local engine');++++function motif() {++ return `<div class="vi-race" aria-hidden="true">++<svg class="vi-race-img" viewBox="0 0 420 260" preserveAspectRatio="none"><rect x="1" y="1" width="418" height="258" rx="18" fill="none" stroke="currentColor" stroke-opacity=".25"/><path d="M70 190 L150 120 L230 150 L350 70" fill="none" stroke="currentColor" stroke-width="3"/><circle cx="70" cy="190" r="6" fill="currentColor"/><circle cx="150" cy="120" r="6" fill="currentColor"/><circle cx="230" cy="150" r="6" fill="currentColor"/><circle cx="350" cy="70" r="6" fill="currentColor"/></svg>++<span class="vi-race-box vi-race-a"><b>local OCR</b><small>0.0000 USD · 41 ms</small></span>++<span class="vi-race-box vi-race-b"><b>vision model</b><small>0.0007 USD · 1.2 s</small></span>++<span class="vi-race-tag">same image, two boxes</span>++</div>`;++}++++function home({ signedIn = false, ai = false } = {}) {++ const tryIt = `<section class="vi-section vi-try" id="vi-try" aria-labelledby="vi-try-h">++<div class="vi-head-row"><h2 id="vi-try-h">Ask a picture</h2><span class="vi-count">${ai ? 'local OCR first, then OpenVibe.AI' : 'local engines only on this host right now'}</span></div>++<p class="vi-lede">Describe, read, translate, identify or count. Upload a file or paste a public image URL; the local reader answers a read question before any model is called.</p>++<div class="vi-actions"><a class="vi-btn" href="/ask">Ask a picture</a><a class="vi-btn vi-btn-quiet" href="/ocr">Read text only</a><a class="vi-btn vi-btn-quiet" href="/compare">Compare models</a><a class="vi-btn vi-btn-quiet" href="/ui-check">Check a page</a></div>++${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your account, so they need <a href="/auth/login?next=%2Fask">an OpenVibe sign-in</a>. OCR and the UI check work without one.</p>'}++</section>`;++ const room = sc.room({++ room: 'vision',++ hero: {++ title: 'See with the best model for the job',++ lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for the overflow, low contrast and layering faults a person would catch. No face recognition, ever.',++ start: { href: '/ask', label: 'Ask a picture' },++ chips: [{ label: 'Read text', href: '/ocr' }, { label: 'Compare models', href: '/compare' }, { label: 'UI check', href: '/ui-check' }, { label: 'The API', href: '/developers' }],++ proof: [{ value: '0.0000', label: 'USD for the local OCR first pass' }],++ motif: motif(),++ },++ scenes: [++ {++ title: 'A receipt, read for nothing',++ text: 'The local reader (Tesseract, on this host) reads the words a receipt actually needs: merchant, date, lines, total. No image leaves the machine and no model is called. Only when the local pass finds no usable text does a signed-in person\'s AI fallback run, and the answer says so.',++ html: `<div class="vi-demo-receipt"><span class="vi-demo-kicker">LOCAL OCR</span><b>OPENVIBE MARKET</b><span>2026-10-28 · 14:02</span><span>Oat milk 3.40</span><span>Bread 2.10</span><strong>TOTAL 5.50</strong><small>confidence 91.4 · 0.0000 USD · 38 ms</small></div>`,++ action: { href: '/ocr', label: 'Read a picture' },++ },++ {++ title: 'The same image, side by side',++ text: 'A compare sends one image to the local reader and to OpenVibe.AI. AI\'s router is the authority: the provider and model shown are the ones that answered, never the hint. Cost comes from AI\'s own run, latency is measured here.',++ html: `<table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table>`,++ action: { href: '/compare', label: 'Run a compare' },++ },++ {++ title: 'The UI check catches the overflow',++ text: 'Drop in HTML, a page URL or a screenshot. The static pass reads the boxes and the colours: a container wider than the viewport, nowrap text clipped by its box, body text under 4.5:1, positioned boxes on top of each other, a z-index with no effect. Arena calls the same API after it generates a page.',++ html: `<div class="vi-demo-check"><div class="vi-demo-page"><span class="vi-demo-overflow">this card is 1400px wide</span><span class="vi-demo-clip">a headline that gets cut off at the edge</span></div><ul><li class="is-bad"><b>overflow</b><span>1400px in a 390px view</span></li><li class="is-bad"><b>cut off</b><span>nowrap inside 120px</span></li><li class="is-bad"><b>contrast</b><span>2.4:1, needs 4.5:1</span></li></ul></div>`,++ action: { href: '/ui-check', label: 'Check a page' },++ },++ ],++ faq: { items: [++ ['Does Vision recognise faces?', 'No. Never. Vision identifies what an image shows without identifying people, and public AI text never describes a person\'s looks. The prompt and the stored answer both enforce the no-looks rule.'],++ ['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],++ ['Are my images stored?', 'No image bytes are stored. An answer keeps a sha256, the media type, the cleaned text and the model provenance. A signed-in person\'s rows are exported and erased with their account.'],++ ] },++ });++ const at = room.indexOf('<section class="sc-room-scenes"');++ return `<div class="vi-wrap vi-home" id="vi-home">${at < 0 ? room : room.slice(0, at) + tryIt + room.slice(at)}</div>`;++}++++function signIn({ title, text, next }) {++ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-sign-in"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="vi-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;++}++++function formError(message) {++ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-form-error"><h1>That did not go through</h1><p>${esc(message)}</p><a class="vi-btn" href="/">Back to Vision</a></div></div>`;++}++++function imageFields({ imageUrl = '', values = {} } = {}) {++ return `<div class="vi-field-grid">++<label class="vi-field"><span>Upload an image</span><input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif"></label>++<label class="vi-field"><span>…or a public image URL</span><input type="url" name="image_url" value="${esc(imageUrl || values.image_url || '')}" placeholder="https://openvibe.media/o/med_…" maxlength="2048"></label>++</div>`;++}++++function ask({ signedIn = false, ai = false, problem = null, values = {} } = {}) {++ const v = { task: 'ask', mode: 'auto', ...values };++ return `<div class="vi-wrap" id="vi-ask">++<header class="vi-page-h"><p class="vi-kicker">Ask a picture</p><h1>What does the image show?</h1>++<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. No face recognition, ever.</p></header>++${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}++${signedIn ? '' : `<p class="vi-note">Ask saves the answer to an account. <a href="/auth/login?next=%2Fask">Sign in with OpenVibe</a> to ask; <a href="/ocr">OCR</a> and the <a href="/ui-check">UI check</a> work without one.</p>`}++${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured on this host. A read question still runs on the local OCR engine.</p>'}++<form class="vi-form vi-card" id="vi-ask-form" method="post" action="/ask" enctype="multipart/form-data">++${imageFields({ values: v })}++<div class="vi-field-grid">++<label class="vi-field"><span>Task</span><select name="task">++${[['ask', 'Ask a question'], ['describe', 'Describe the image'], ['identify', 'Identify what it is'], ['count', 'Count something'], ['translate', 'Read and translate'], ['ocr', 'Read the text']].map(([k, label]) => `<option value="${k}"${v.task === k ? ' selected' : ''}>${label}</option>`).join('')}++</select></label>++<label class="vi-field"><span>Mode</span><select name="mode">++${[['auto', 'Auto: cheapest engine first'], ['local', 'Local only: no model call'], ['ai', 'AI directly']].map(([k, label]) => `<option value="${k}"${v.mode === k ? ' selected' : ''}>${label}</option>`).join('')}++</select></label>++</div>++<label class="vi-field"><span>Question</span><input name="question" type="text" maxlength="2000" value="${esc(v.question || '')}" placeholder="What does the sign say? How many bottles are there?"></label>++<div class="vi-actions"><button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Ask</button><a class="vi-btn vi-btn-quiet" href="/ocr">Just read the text</a></div>++${signedIn ? '' : '<p class="vi-fine">The Ask button needs a signed-in account; the other pages do not.</p>'}++</form>++</div>`;++}++++function ocr({ signedIn = false, ai = false, problem = null, result = null } = {}) {++ return `<div class="vi-wrap" id="vi-ocr">++<header class="vi-page-h"><p class="vi-kicker">Local first</p><h1>Read text from a picture</h1>++<p class="vi-lede">Tesseract 5 runs on this host. Nothing is sent to a model unless you are signed in, tick the fallback, and the local pass finds no usable text. The reading is stored as text; the image is not.</p></header>++${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}++<div class="vi-two-col">++<form class="vi-form vi-card" id="vi-ocr-form" method="post" action="/ocr" enctype="multipart/form-data">++${imageFields()}++<label class="vi-field"><span>Language</span><input name="lang" type="text" value="eng" maxlength="15" pattern="[a-z]{3}(\\+[a-z]{3}){0,3}"></label>++<label class="vi-check"><input type="checkbox" name="fallback" value="1"><span>If the local reader finds no text, ask OpenVibe.AI${signedIn ? '' : ' (needs a sign-in)'}</span></label>++<div class="vi-actions"><button class="vi-btn" type="submit">Read locally</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask a question instead</a></div>++${ai ? '' : '<p class="vi-fine">AI fallback is off on this host.</p>'}++</form>++${result ? `<aside class="vi-card vi-result" id="vi-ocr-result"><h2>Reading</h2><pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre><dl class="vi-facts"><div><dt>Engine</dt><dd>${esc(result.model || result.engine || 'local')}</dd></div><div><dt>Confidence</dt><dd>${result.confidence == null ? '—' : esc(result.confidence)}</dd></div><div><dt>Time</dt><dd>${Number(result.latency_ms) || 0} ms</dd></div><div><dt>Cost</dt><dd>${esc(Number(result.cost_usd) || 0)} USD</dd></div></dl><p class="vi-fine">${esc(result.reason || 'The local reader answered first.')}</p></aside>` : '<aside class="vi-card vi-result vi-muted" id="vi-ocr-empty"><h2>No image yet</h2><p>Upload a PNG, JPEG, WebP, GIF, BMP, TIFF or AVIF and press Read locally. The words come back in reading order.</p></aside>'}++</div>++</div>`;++}++++function compare({ signedIn = false, ai = false, models = [], configured = [], problem = null, values = {} } = {}) {++ const v = { task: 'ask', ...values };++ const options = [];++ const seen = new Set();++ for (const m of [...models.map((x) => (x.provider && x.provider !== 'ai' ? `${x.provider}:${x.model || ''}` : (x.model || 'openvibe'))), ...configured]) {++ if (m && !seen.has(m)) { seen.add(m); options.push(m); }++ }++ if (!options.length) options.push('openvibe');++ return `<div class="vi-wrap" id="vi-compare">++<header class="vi-page-h"><p class="vi-kicker">Side by side</p><h1>Compare models on one image</h1>++<p class="vi-lede">The local OCR reader runs first and is free. Each AI hint is one OpenVibe.AI run; the provider and model that answer are the ones AI reports, never the hint. Cost and latency are measured per engine.</p></header>++${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}++${signedIn ? '' : '<p class="vi-note">A compare spends an AI run, so it needs <a href="/auth/login?next=%2Fcompare">an OpenVibe sign-in</a>.</p>'}++${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured: a compare would show the local reader only.</p>'}++<form class="vi-form vi-card" id="vi-compare-form" method="post" action="/compare" enctype="multipart/form-data">++${imageFields({ values: v })}++<label class="vi-field"><span>Question</span><input name="question" type="text" maxlength="2000" value="${esc(v.question || '')}" placeholder="Read and describe this image."></label>++<label class="vi-field"><span>Task</span><select name="task">${[['ask', 'Ask'], ['describe', 'Describe'], ['ocr', 'Read'], ['count', 'Count'], ['identify', 'Identify'], ['translate', 'Translate']].map(([k, label]) => `<option value="${k}"${v.task === k ? ' selected' : ''}>${label}</option>`).join('')}</select></label>++<fieldset class="vi-models"><legend>AI model hints <span class="vi-fine">(up to three; AI may route elsewhere)</span></legend>++${options.map((m) => `<label class="vi-check"><input type="checkbox" name="models" value="${esc(m)}" checked><span>${esc(m)}</span></label>`).join('')}++</fieldset>++<div class="vi-actions"><button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Compare on this image</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask one model</a></div>++</form>++</div>`;++}++++function uiCheck({ signedIn = false, problem = null, result = null, values = {} } = {}) {++ return `<div class="vi-wrap" id="vi-ui-check">++<header class="vi-page-h"><p class="vi-kicker">The UI check</p><h1>Find what a person would see</h1>++<p class="vi-lede">Overflow, cut-off text, low contrast, overlapping boxes and layering faults. Paste generated HTML, name a public page URL, or upload a screenshot. The static pass does not execute JavaScript and does not fetch remote stylesheets; it says so in the result.</p></header>++${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}++<div class="vi-two-col">++<form class="vi-form vi-card" id="vi-ui-form" method="post" action="/ui-check" enctype="multipart/form-data">++<label class="vi-field"><span>HTML</span><textarea name="html" rows="10" maxlength="2000000" placeholder="<!doctype html>…">${esc(values.html || '')}</textarea></label>++<label class="vi-field"><span>…or a public page URL</span><input type="url" name="url" value="${esc(values.url || '')}" placeholder="https://openvibe.vision/" maxlength="2048"></label>++<div class="vi-field-grid">++<label class="vi-field"><span>…or a screenshot</span><input type="file" name="image" accept="image/*"></label>++<label class="vi-field"><span>Viewport width</span><input type="number" name="viewport_w" min="160" max="10000" value="${esc(values.viewport_w || 1280)}"></label>++<label class="vi-field"><span>Viewport height</span><input type="number" name="viewport_h" min="160" max="10000" value="${esc(values.viewport_h || 800)}"></label>++</div>++<div class="vi-actions"><button class="vi-btn" type="submit">Check it</button><a class="vi-btn vi-btn-quiet" href="/developers">Use the API</a></div>++<p class="vi-fine">${signedIn ? 'The result is saved to your account.' : 'The result is shown here and not saved. <a href="/auth/login?next=%2Fui-check">Sign in</a> to keep it.'}</p>++</form>++${result ? resultPanel(result) : '<aside class="vi-card vi-result vi-muted" id="vi-ui-empty"><h2>No page checked yet</h2><p>Start with a screenshot at 390 px wide, or paste a generated HTML document. Findings are returned as data: kind, severity, selector, detail and evidence.</p></aside>'}++</div>++</div>`;++}++++function resultPanel(r) {++ return `<aside class="vi-card vi-result" id="vi-ui-result">++<h2>${esc(r.summary || (r.findings && r.findings.length ? `${r.findings.length} findings` : 'No faults found'))}</h2>++<dl class="vi-facts"><div><dt>Engine</dt><dd>${esc(r.engine || 'static-html')}</dd></div><div><dt>Viewport</dt><dd>${Number(r.viewport && r.viewport.w) || 0} × ${Number(r.viewport && r.viewport.h) || 0}</dd></div><div><dt>Findings</dt><dd>${Number(r.findings && r.findings.length) || 0}</dd></div><div><dt>Source</dt><dd>${esc(r.source || 'html')}</dd></div></dl>++${findingsTable(r.findings || [])}++${(r.warnings || []).length ? `<ul class="vi-warnings">${r.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : ''}++</aside>`;++}++++function findingsTable(findings = []) {++ if (!findings.length) return '<p class="vi-note is-good" role="status">No overflow, cut-off text, low contrast, overlap or layering fault was found by this pass.</p>';++ return `<div class="vi-table-wrap"><table class="vi-table" id="vi-findings"><thead><tr><th scope="col">Fault</th><th scope="col">Severity</th><th scope="col">Selector</th><th scope="col">What</th></tr></thead><tbody>${findings.slice(0, 200).map((f) => `<tr class="vi-finding vi-finding-${esc(f.kind)} is-${esc(f.severity)}"><td><b>${esc(f.kind)}</b></td><td>${esc(f.severity)}</td><td><code>${esc(f.selector)}</code></td><td>${esc(f.detail)}</td></tr>`).join('')}</tbody></table></div>`;++}++++function answer({ a, mine = false } = {}) {++ const samples = a.answer && Array.isArray(a.answer.samples) ? a.answer.samples : null;++ const words = a.answer && Array.isArray(a.answer.words) ? a.answer.words : null;++ return `<div class="vi-wrap" id="vi-answer" data-answer="${esc(a.id)}">++<header class="vi-page-h"><p class="vi-kicker">${esc(a.task)} · ${mine ? 'yours' : 'staff view'}</p><h1>${esc(a.model ? MODEL_LABEL(a) : 'The local reader')}</h1>++${a.question ? `<p class="vi-question">${esc(a.question)}</p>` : ''}</header>++<section class="vi-answer-card vi-card">++${a.text ? `<pre class="vi-answer-text">${esc(a.text)}</pre>` : '<p class="vi-muted">No answer text was stored.</p>'}++<dl class="vi-facts">++<div><dt>Engine</dt><dd>${esc(a.answer && a.answer.engine ? a.answer.engine : 'local')}</dd></div>++<div><dt>Model</dt><dd>${esc(MODEL_LABEL(a))}</dd></div>++<div><dt>Why</dt><dd>${esc(a.reason || '—')}</dd></div>++<div><dt>Cost</dt><dd>${esc(Number(a.cost_usd) || 0)} USD</dd></div>++<div><dt>Time</dt><dd>${Number(a.latency_ms) || 0} ms</dd></div>++<div><dt>Image</dt><dd><code>${esc(a.image_sha256)}</code> · ${esc(a.image_mime)} · ${Number(a.image_bytes) || 0} bytes</dd></div>++<div><dt>Asked</dt><dd>${time(a.created_at)}</dd></div>++</dl>++</section>++${samples ? `<section class="vi-section" id="vi-compare-result"><h2>Side by side</h2><div class="vi-table-wrap"><table class="vi-table"><thead><tr><th scope="col">Engine</th><th scope="col">Requested</th><th scope="col">Answered by</th><th scope="col">Cost</th><th scope="col">Time</th><th scope="col">Answer</th></tr></thead><tbody>${samples.map((s) => `<tr><td>${esc(s.engine)}</td><td>${esc(s.requested_model || '—')}</td><td>${esc(s.model ? `${s.provider || ''}:${s.model}` : '—')}</td><td>${esc(Number(s.cost_usd) || 0)}</td><td>${Number(s.latency_ms) || 0} ms</td><td>${s.ok ? `<span class="vi-sample-text">${esc(s.text || '')}</span>` : `<span class="vi-note is-bad">${esc(s.error_code || 'failed')}</span>`}</td></tr>`).join('')}</tbody></table></div></section>` : ''}++${words && words.length ? `<section class="vi-section" id="vi-words"><h2>Word boxes</h2><p class="vi-fine">${words.length} words with their confidence and position; the screenshot UI check uses the same boxes.</p><div class="vi-table-wrap"><table class="vi-table"><thead><tr><th scope="col">Word</th><th scope="col">Confidence</th><th scope="col">x, y</th><th scope="col">w × h</th></tr></thead><tbody>${words.slice(0, 200).map((w) => `<tr><td>${esc(w.text)}</td><td>${esc(w.conf)}</td><td>${Number(w.x) || 0}, ${Number(w.y) || 0}</td><td>${Number(w.w) || 0} × ${Number(w.h) || 0}</td></tr>`).join('')}</tbody></table></div></section>` : ''}++<div class="vi-actions"><a class="vi-btn" href="/ask">Ask another</a><a class="vi-btn vi-btn-quiet" href="/compare">Compare models</a></div>++</div>`;++}++++function uiCheckResult({ c, mine = false } = {}) {++ return `<div class="vi-wrap" id="vi-check" data-check="${esc(c.id)}">++<header class="vi-page-h"><p class="vi-kicker">UI check · ${mine ? 'yours' : 'staff view'}</p><h1>${esc(c.summary && c.summary.summary ? c.summary.summary : 'A UI check')}</h1>++<p class="vi-lede">${esc(c.engine)} · ${Number(c.viewport.w)} × ${Number(c.viewport.h)} · ${esc(c.source)}${c.url ? ` · <a href="${esc(c.url)}" rel="nofollow noopener">${esc(c.url)}</a>` : ''}</p></header>++${findingsTable(c.findings)}++${(c.summary && c.summary.warnings || []).length ? `<ul class="vi-warnings">${c.summary.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : ''}++<div class="vi-actions"><a class="vi-btn" href="/ui-check">Check another</a><a class="vi-btn vi-btn-quiet" href="/developers">Use the API</a></div>++</div>`;++}++++function developers({ ai = false, models = [] } = {}) {++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;++ return `<div class="vi-wrap" id="vi-developers">++<header class="vi-page-h"><p class="vi-kicker">Developers</p><h1>The Vision API</h1>++<p class="vi-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>vision_at</code> session cookie or a Network user token as a Bearer. A service token needs the capability on the route; Design's Arena calls the UI check with <code>vision.ui.check</code>.</p></header>++<section><h2>Ask, read, compare</h2><dl class="vi-eps">++${ep('POST', '/api/v1/ask (alias /api/v1/describe)', 'One image and a question: <code>{ task, question, mode, image_url }</code>, a multipart <code>image</code>, or an <code>image</code> data URL. Local OCR answers a read question first, otherwise OpenVibe.AI. Capability: <code>vision.ask</code>.')}++${ep('POST', '/api/v1/ocr', 'The local Tesseract reader. <code>fallback=1</code> asks AI only when no text is found and the caller is signed in or holds <code>vision.ocr</code>.')}++${ep('POST', '/api/v1/compare', 'The same image through local OCR and one to three AI hints (<code>models</code>). Returns each engine\'s answer, provider, model, cost and latency. Capability: <code>vision.compare</code>.')}++${ep('POST', '/api/v1/ui-check', 'HTML, URL or screenshot with <code>viewport</code>. Returns <code>findings</code>: overflow, cut_off, contrast, overlap and layering. Capability: <code>vision.ui.check</code>.')}++${ep('GET', '/api/v1/answers/:id', 'One saved answer, for its owner or a service holding the route capability.')}++${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a service holding <code>vision.ui.check</code>.')}++${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}++${ep('GET', '/api/v1/benchmarks?task=&limit=', 'Aggregate compare samples: task, engine, provider, model, samples, mean latency and mean cost. No subject data.')}++</dl></section>++<section id="vi-capabilities"><h2>Capabilities and grants</h2>++<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code> and <code>vision.ui.check</code>; the main session registers the manifests and grants them to Design (the Arena) and any other caller.</p>++<pre class="vi-code">POST /api/v1/ui-check++Authorization: Bearer <Network service token for openvibe.vision>++Content-Type: application/json++++{ "html": "<!doctype html>…", "viewport": { "w": 390, "h": 844 } }</pre>++</section>++<section id="vi-limits"><h2>What leaves this host</h2>++<p class="vi-fine">The local OCR engine and the UI check never leave the host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; AI's own routing, quotas and provenance apply. Vision holds no provider key. No image bytes are stored, and no face recognition is ever used. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>++<p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p>++</section>++<section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>++</div>`;++}++++function notFound() {++ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-not-found"><p class="vi-kicker">404</p><h1>This answer is not here</h1><p>It may belong to another person, or the address was never one of ours.</p><a class="vi-btn" href="/">Back to Vision</a></div></div>`;++}++++module.exports = {++ home, ask, ocr, compare, uiCheck, answer, uiCheckResult, developers, notFound, signIn, formError,++ resultPanel, findingsTable, motif,++};nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++++/**++ * OpenVibe.AI is the only model gateway Vision uses. There is no provider key here, no direct provider URL, and++ * no paid provider of Vision's own: a first-party service token (client `vision`, audience `openvibe.ai`,++ * capability `ai.run.create`, namespace `ai.*`) reaches the OpenAI-compatible endpoint, so AI's routing, quotas,++ * cache, provenance and cost accounting all apply. `x_openvibe` is kept on every answer, because the answer must++ * say which model answered and why.++ *++ * The model in the request is a hint only: AI's router is the authority and may answer from a pool or a fallback.++ * The provider and model Vision reports are always the ones AI says answered, never the hint.++ */++const { serviceAuth } = require('openvibe-contracts');++const { cleanAnswer } = require('../text/no-looks');++++class AiError extends Error {++ constructor(status, code, detail) {++ super(detail || code);++ this.name = 'AiError';++ this.status = status;++ this.code = code;++ this.detail = detail || code;++ }++}++++const TASK_PROMPTS = {++ ask: 'Answer the question about the image precisely. Say when the image does not show the answer. If the answer is a count, give the number and name what you counted.',++ describe: 'Describe the image for someone who cannot see it: the scene, the objects, any visible text, and what is happening. Do not describe anyone\'s appearance.',++ identify: 'Identify what the image shows. Give the most specific answer the picture supports, and say what is uncertain.',++ count: 'Count what the question asks about. Give the number first, then list each item you counted.',++ translate: 'Read the visible text and translate it into the target language. Give the original text, then the translation.',++ ocr: 'Read every visible piece of text in the image, verbatim, preserving line breaks and reading order. Do not summarize and do not add anything that is not written.',++ compare: 'Answer the question about the image precisely, for a side-by-side comparison. Say when the image does not show the answer.',++};++++const NO_LOOKS = 'Never describe a person\'s body, face, hair, clothing, age, race, gender, disability or guessed identity. Never identify, name or recognise a person, and never use a face as identity. If the answer would need that, say you cannot describe people\'s appearance and answer about the rest of the picture.';++++function textOf(content) {++ if (typeof content === 'string') return content;++ if (Array.isArray(content)) {++ return content.map((p) => {++ if (!p || typeof p !== 'object') return '';++ if (typeof p.text === 'string') return p.text;++ if (p.type === 'output_text' && typeof p.text === 'string') return p.text;++ return '';++ }).join('').trim();++ }++ return '';++}++++function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {}) {++ const ai = config.ai;++ const tokenClient = config.oauth.clientSecret++ ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: ai.audience,++ scope: ai.scope,++ fetchImpl,++ })++ : null;++ const enabled = Boolean(ai.url && tokenClient);++++ async function call(method, path, body, { retried = false } = {}) {++ if (!enabled) throw new AiError(503, 'vision.ai_unavailable', 'OpenVibe.AI is not configured: OV_OAUTH_CLIENT_SECRET is unset or OV_AI_URL is empty');++ let headers;++ try { headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) }; }++ catch { throw new AiError(502, 'vision.ai_token_failed', 'OpenVibe.Network did not issue an AI token for Vision'); }++ let res;++ try {++ res = await fetchImpl(`${String(ai.url).replace(/\/+$/, '')}${path}`, {++ method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(ai.timeoutMs),++ });++ } catch (err) {++ throw new AiError(502, 'vision.ai_unreachable', err && err.name === 'TimeoutError' ? 'OpenVibe.AI did not answer in time' : 'OpenVibe.AI could not be reached');++ }++ if (res.status === 401 && !retried) {++ try { tokenClient.invalidate(); } catch { /* older client */ }++ return call(method, path, body, { retried: true });++ }++ const json = await res.json().catch(() => null);++ if (!res.ok) {++ const err = json && json.error && typeof json.error === 'object' ? json.error : null;++ const code = (err && err.code) || (json && (json.code || json.error)) || `ai.http_${res.status}`;++ const detail = (err && err.message) || (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status})`;++ throw new AiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));++ }++ return json;++ }++++ /** The route's offered provider:model pairs; a hint for the compare slots, never a promise of the answer. */++ async function offeredModels() {++ if (!enabled) return [];++ try {++ const body = await call('GET', '/api/v1/routes');++ const routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);++ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));++ const ordered = [...chat.filter((r) => r.key === 'default.chat'), ...chat.filter((r) => r.key !== 'default.chat')];++ const out = [];++ const seen = new Set();++ for (const r of ordered) {++ for (const c of [r.primary, ...(Array.isArray(r.pinned) ? r.pinned : []), ...(Array.isArray(r.fallbacks) ? r.fallbacks : [])]) {++ if (!c || !c.provider) continue;++ const key = `${c.provider}|${c.model || ''}`;++ if (seen.has(key)) continue;++ seen.add(key);++ out.push({ provider: String(c.provider).slice(0, 80), model: c.model == null ? null : String(c.model).slice(0, 120) });++ if (out.length >= 6) return out;++ }++ }++ return out;++ } catch (err) {++ log.warn(`[OpenVibe.Vision] AI routes unavailable: ${(err && err.message) || err}`);++ return [];++ }++ }++++ /**++ * One image question through AI. opts: dataUrl, task, question, model (a hint), target.++ * Returns the cleaned text plus AI's own provenance and usage. Throws AiError.++ */++ async function answer({ dataUrl, task = 'ask', question = '', model = null, target = null, maxTokens = null, temperature = null } = {}) {++ const prompt = TASK_PROMPTS[task] || TASK_PROMPTS.ask;++ const asked = String(question || '').trim().slice(0, 2000);++ const body = {++ // `model` is documented as a hint; AI's router picks and reports. Including it keeps the request honest.++ ...(model ? { model: String(model).slice(0, 120) } : {}),++ messages: [++ { role: 'system', content: `You are OpenVibe.Vision's image reader. ${prompt}\n${NO_LOOKS}\nAnswer plainly and briefly; never invent details the picture does not show.` },++ { role: 'user', content: [++ { type: 'text', text: asked || 'Describe this image.' },++ { type: 'image_url', image_url: { url: dataUrl } },++ ] },++ ],++ max_tokens: Math.min(2000, Math.max(32, Number(maxTokens) || ai.maxTokens)),++ temperature: temperature == null ? ai.temperature : temperature,++ };++ const started = Date.now();++ const out = await call('POST', '/v1/chat/completions', body);++ const choice = out && Array.isArray(out.choices) ? out.choices[0] : null;++ const raw = textOf(choice && choice.message && choice.message.content);++ if (!raw.trim()) throw new AiError(502, 'vision.ai_empty', 'OpenVibe.AI answered without any text');++ const x = out.x_openvibe || {};++ const usage = out.usage || {};++ return {++ text: cleanAnswer(raw),++ model: String(out.model || (x.model || '') || (model || 'openvibe')).slice(0, 120),++ provider: String(x.provider || '').slice(0, 80) || null,++ route: 'default.chat',++ costUsd: Math.max(0, Number(x.cost_usd != null ? x.cost_usd : 0) || 0),++ ms: Date.now() - started,++ runId: x.run_id || null,++ synthetic: Boolean(x.synthetic),++ usage: { tokens_in: Number(usage.prompt_tokens) || 0, tokens_out: Number(usage.completion_tokens) || 0 },++ reason: `OpenVibe.AI routed a ${task} run to ${x.provider || 'its pool'}${out.model ? `:${out.model}` : ''}; the answer is cleaned by noLooks`,++ target: target || null,++ };++ }++++ return { enabled, answer, offeredModels, call, tokenClient };++}++++module.exports = { createAi, AiError, TASK_PROMPTS, NO_LOOKS };nnew file mode 100644@@@ -0,0 +1,203 @@++'use strict';++++/**++ * Image inputs. An API caller may upload bytes (multipart), send a data: URI, or name a public https URL.++ * The URL path is fetched only after every resolved address is checked: loopback, link-local, private and++ * unique-local addresses are refused (SSRF), redirects are re-checked, and the bytes must really be an image.++ *++ * Nothing here stores an image. The answer writes only sha256, mime and byte count; OCR gets a temporary file++ * that is removed in a finally block.++ */++const crypto = require('crypto');++const dns = require('dns').promises;++const fs = require('fs');++const net = require('net');++const os = require('os');++const path = require('path');++const sharp = require('sharp');++++const DATA_URL_RE = /^data:(image\/(?:png|jpe?g|webp|gif|bmp|tiff|avif));base64,([A-Za-z0-9+/=\s]+)$/i;++const MIME_BY_MAGIC = [++ { mime: 'image/png', test: (b) => b.length > 8 && b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG' },++ { mime: 'image/jpeg', test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },++ { mime: 'image/webp', test: (b) => b.length > 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' },++ { mime: 'image/gif', test: (b) => b.length > 6 && (b.toString('ascii', 0, 6) === 'GIF87a' || b.toString('ascii', 0, 6) === 'GIF89a') },++ { mime: 'image/bmp', test: (b) => b.length > 2 && b.toString('ascii', 0, 2) === 'BM' },++ { mime: 'image/tiff', test: (b) => b.length > 4 && (b.toString('ascii', 0, 2) === 'II' || b.toString('ascii', 0, 2) === 'MM') },++ { mime: 'image/avif', test: (b) => b.length > 12 && b.toString('ascii', 4, 12) === 'ftypavif' },++];++++function isPrivateAddress(address) {++ if (!net.isIP(address)) return true;++ if (net.isIPv4(address)) {++ const p = address.split('.').map(Number);++ if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;++ if (p[0] === 169 && p[1] === 254) return true;++ if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;++ if (p[0] === 192 && p[1] === 168) return true;++ if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;++ if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true;++ if (p[0] >= 224) return true;++ return false;++ }++ const a = address.toLowerCase();++ if (a === '::' || a === '::1') return true;++ if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) return true;++ if (a.startsWith('fc') || a.startsWith('fd')) return true;++ if (a.startsWith('ff')) return true;++ if (a.startsWith('::ffff:')) return isPrivateAddress(a.slice(7));++ return false;++}++++async function assertPublicUrl(raw, { allowPrivate = false } = {}) {++ let u;++ try { u = new URL(String(raw)); } catch { const e = new Error('that image URL is not a URL'); e.code = 'image.invalid_url'; throw e; }++ if (!/^https?:$/.test(u.protocol)) { const e = new Error('an image URL must be http or https'); e.code = 'image.invalid_url'; throw e; }++ if (u.username || u.password) { const e = new Error('an image URL may not carry credentials'); e.code = 'image.invalid_url'; throw e; }++ if (!allowPrivate) {++ const host = u.hostname.replace(/^\[|\]$/g, '');++ if (net.isIP(host)) {++ if (isPrivateAddress(host)) { const e = new Error('that address is private; Vision fetches public images only'); e.code = 'image.private_url'; throw e; }++ } else {++ let rows;++ try { rows = await dns.lookup(host, { all: true, verbatim: true }); } catch { const e = new Error('that image host does not resolve'); e.code = 'image.unreachable'; throw e; }++ if (!rows.length || rows.some((r) => isPrivateAddress(r.address))) {++ const e = new Error('that host resolves to a private address; Vision fetches public images only');++ e.code = 'image.private_url';++ throw e;++ }++ }++ }++ return u;++}++++function sniff(buffer) {++ for (const m of MIME_BY_MAGIC) if (m.test(buffer)) return m.mime;++ return null;++}++++function decodeDataUrl(dataUrl, maxBytes) {++ const m = DATA_URL_RE.exec(String(dataUrl || '').trim());++ if (!m) { const e = new Error('image must be a base64 data:image/png|jpeg|webp|gif|bmp|tiff|avif URL'); e.code = 'image.invalid'; throw e; }++ const buffer = Buffer.from(m[2].replace(/\s+/g, ''), 'base64');++ if (!buffer.length) { const e = new Error('the image data is empty'); e.code = 'image.invalid'; throw e; }++ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }++ const mime = sniff(buffer);++ if (!mime) { const e = new Error('the data URL does not contain a supported image'); e.code = 'image.unsupported'; throw e; }++ return { buffer, mime };++}++++async function readBody(res, maxBytes) {++ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();++ if (!/^image\//.test(type)) { const e = new Error(`expected an image, got ${type || 'an unknown type'}`); e.code = 'image.not_image'; throw e; }++ const len = Number(res.headers.get('content-length'));++ if (Number.isFinite(len) && len > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }++ const ab = await res.arrayBuffer();++ const buffer = Buffer.from(ab);++ if (!buffer.length) { const e = new Error('the image URL answered with no bytes'); e.code = 'image.empty'; throw e; }++ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }++ const mime = sniff(buffer);++ if (!mime) { const e = new Error('the URL did not answer with a supported image'); e.code = 'image.unsupported'; throw e; }++ return { buffer, mime };++}++++async function fetchImage(rawUrl, { fetchImpl = globalThis.fetch, timeoutMs = 10000, maxBytes = 8 * 1024 * 1024, allowPrivate = false } = {}) {++ let url = await assertPublicUrl(rawUrl, { allowPrivate });++ for (let hop = 0; hop < 4; hop++) {++ let res;++ try { res = await fetchImpl(url.toString(), { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'image/*', 'User-Agent': 'OpenVibe.Vision/0.1 image reader' } }); }++ catch (err) { const e = new Error(err && err.name === 'TimeoutError' ? 'the image host did not answer in time' : 'the image host could not be reached'); e.code = err && err.name === 'TimeoutError' ? 'image.timeout' : 'image.unreachable'; throw e; }++ if (res.status >= 300 && res.status < 400) {++ const loc = res.headers.get('location');++ if (!loc) { const e = new Error('the image host redirected without a location'); e.code = 'image.unreachable'; throw e; }++ url = await assertPublicUrl(new URL(loc, url).toString(), { allowPrivate });++ continue;++ }++ if (!res.ok) { const e = new Error(`the image host answered ${res.status}`); e.code = 'image.unreachable'; throw e; }++ return await readBody(res, maxBytes);++ }++ const e = new Error('too many redirects for that image'); e.code = 'image.unreachable'; throw e;++}++++function sha256(buffer) {++ return crypto.createHash('sha256').update(buffer).digest('hex');++}++++/**++ * The one entry point routes use. opts: file (multer buffer), contentType, imageUrl, image (data URL or plain++ * base64), fetchImpl, timeoutMs, maxMb, allowPrivate.++ */++async function loadImage(opts = {}) {++ const config = opts.config || {};++ const maxMb = Math.min(20, Math.max(1, Number(opts.maxMb || (config.images && config.images.maxMb) || 8)));++ const maxBytes = maxMb * 1024 * 1024;++ const timeoutMs = Number(opts.timeoutMs || (config.images && config.images.timeoutMs) || 10000);++ const allowPrivate = opts.allowPrivate != null ? opts.allowPrivate : Boolean(config.images && config.images.allowPrivate);++++ let buffer; let mime; let source; let sourceUrl = null;++ if (opts.file) {++ buffer = Buffer.from(opts.file);++ mime = sniff(buffer);++ if (!mime) { const e = new Error('that file is not a supported image'); e.code = 'image.unsupported'; throw e; }++ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }++ source = 'upload';++ } else if (opts.imageUrl) {++ const got = await fetchImage(opts.imageUrl, { fetchImpl: opts.fetchImpl, timeoutMs, maxBytes, allowPrivate });++ buffer = got.buffer; mime = got.mime; source = 'url'; sourceUrl = String(opts.imageUrl).slice(0, 2048);++ } else if (opts.image) {++ const got = decodeDataUrl(opts.image, maxBytes);++ buffer = got.buffer; mime = got.mime; source = 'data';++ } else {++ const e = new Error('send an image file, an image data URL, or image_url'); e.code = 'image.required'; throw e;++ }++ if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/tiff', 'image/avif'].includes(mime)) {++ const e = new Error('that image type is not supported'); e.code = 'image.unsupported'; throw e;++ }++ // A compressed image can decode to an enormous surface. 40 MP is far beyond a screenshot or a photo a person++ // asks about, and keeps the local passes (sharp pixels, Tesseract) inside a predictable memory budget.++ try {++ const meta = await sharp(buffer, { failOn: 'none', limitInputPixels: 40_000_000 }).metadata();++ if (!meta || !meta.width || !meta.height) throw new Error('no dimensions');++ } catch {++ const e = new Error('that image could not be decoded (or is larger than 40 megapixels)'); e.code = 'image.invalid'; throw e;++ }++ return { buffer, mime, bytes: buffer.length, sha256: sha256(buffer), source, sourceUrl };++}++++/** Normalise for a vision model: rotate by EXIF, fit inside 1536 px, JPEG (the form every provider reads). */++async function forModel(buf) {++ const out = await sharp(buf, { failOn: 'none', animated: false })++ .rotate().resize({ width: 1536, height: 1536, fit: 'inside', withoutEnlargement: true })++ .jpeg({ quality: 82 }).toBuffer();++ return { buffer: out, mime: 'image/jpeg', dataUrl: `data:image/jpeg;base64,${out.toString('base64')}` };++}++++/** Normalise for Tesseract: rotate, grayscale, normalise contrast, fit inside 4000 px, PNG. */++async function forOcr(buf) {++ const out = await sharp(buf, { failOn: 'none', animated: false })++ .rotate().resize({ width: 4000, height: 4000, fit: 'inside', withoutEnlargement: true })++ .grayscale().normalise().png().toBuffer();++ return { buffer: out, mime: 'image/png' };++}++++/** Metadata for a screenshot answer; no bytes are kept. */++async function metadata(buf) {++ const m = await sharp(buf, { failOn: 'none' }).metadata();++ return { width: Number(m.width) || 0, height: Number(m.height) || 0, format: m.format || null };++}++++async function withTempImage(buffer, ext, fn) {++ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openvibe-vision-'));++ const file = path.join(dir, `image.${ext || 'png'}`);++ try {++ await fs.promises.writeFile(file, buffer);++ return await fn(file);++ } finally {++ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});++ }++}++++module.exports = {++ loadImage, fetchImage, assertPublicUrl, isPrivateAddress, sniff, sha256,++ forModel, forOcr, metadata, withTempImage,++};nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * The local reader: Tesseract 5, called as a child process with an argument array (never a shell). It is the++ * classical OCR step the plan names first; no image leaves this host and no model is called. A missing binary is++ * an honest 503 (`vision.ocr_unavailable`), not a silent cloud fallback.++ *++ * `recognize()` returns the text, the mean word confidence and the word boxes. The UI check's screenshot path++ * reuses the boxes to find text at an edge or under another box.++ */++const { execFile } = require('child_process');++const { promisify } = require('util');++const { forOcr, withTempImage } = require('./images');++++const run = promisify(execFile);++const LANG_RE = /^[a-z]{3}(?:\+[a-z]{3}){0,3}$/;++++function parseTsv(tsv) {++ const words = [];++ const lines = new Map();++ for (const raw of String(tsv || '').split(/\r?\n/)) {++ if (!raw || /^level\t/.test(raw)) continue;++ const c = raw.split('\t');++ if (c.length < 12) continue;++ const level = Number(c[0]);++ if (level !== 5) continue;++ const text = String(c[11] == null ? '' : c[11]).trim();++ if (!text) continue;++ const conf = Number(c[10]);++ const box = { x: Number(c[6]) || 0, y: Number(c[7]) || 0, w: Number(c[8]) || 0, h: Number(c[9]) || 0 };++ const key = `${c[1]}/${c[2]}/${c[3]}/${c[4]}`;++ words.push({ text, conf: Number.isFinite(conf) ? conf : -1, ...box });++ if (!lines.has(key)) lines.set(key, []);++ lines.get(key).push({ text, ...box });++ }++ const sorted = [...lines.entries()].sort((a, b) => {++ const ay = Math.min(...a[1].map((w) => w.y)); const by = Math.min(...b[1].map((w) => w.y));++ if (ay !== by) return ay - by;++ return Math.min(...a[1].map((w) => w.x)) - Math.min(...b[1].map((w) => w.x));++ });++ const text = sorted.map(([, ws]) => ws.sort((a, b) => a.x - b.x).map((w) => w.text).join(' ')).join('\n').trim();++ const scored = words.filter((w) => w.conf >= 0);++ const confidence = scored.length ? scored.reduce((n, w) => n + w.conf, 0) / scored.length : 0;++ return { text, confidence: Math.round(confidence * 10) / 10, words };++}++++function createOcr({ config, log = console } = {}) {++ const bin = (config && config.ocr && config.ocr.bin) || 'tesseract';++ const lang = (config && config.ocr && config.ocr.lang) || 'eng';++ const timeoutMs = (config && config.ocr && config.ocr.timeoutMs) || 20000;++ let version = null;++ let checked = false;++++ async function available() {++ if (checked) return version;++ checked = true;++ try {++ const { stdout } = await run(bin, ['--version'], { timeout: 5000, maxBuffer: 256 * 1024, env: { ...process.env, OMP_THREAD_LIMIT: process.env.OMP_THREAD_LIMIT || '1' } });++ version = String(stdout || '').split('\n')[0].trim().slice(0, 120) || 'tesseract';++ } catch (err) {++ version = null;++ log.warn(`[OpenVibe.Vision] local OCR unavailable (${bin}): ${err && err.code === 'ENOENT' ? 'not installed' : String(err && err.message || err).slice(0, 120)}`);++ }++ return version;++ }++++ function language(value) {++ const l = String(value || lang).trim();++ return LANG_RE.test(l) ? l : lang;++ }++++ /**++ * OCR one image buffer. Never throws for a bad image or a missing binary: returns { ok, ... } so a route can++ * answer a stable problem code.++ */++ async function recognize(buffer, { lang: wanted = null } = {}) {++ const v = await available();++ if (!v) return { ok: false, code: 'vision.ocr_unavailable', detail: 'the local OCR engine (tesseract) is not installed on this host' };++ let png;++ try { png = await forOcr(buffer); }++ catch { return { ok: false, code: 'vision.image_invalid', detail: 'the image could not be decoded for OCR' }; }++ const started = Date.now();++ try {++ const { stdout } = await withTempImage(png.buffer, 'png', (file) => run(bin, [file, 'stdout', '-l', language(wanted), '--psm', '3', 'tsv'], {++ timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',++ env: { ...process.env, OMP_THREAD_LIMIT: process.env.OMP_THREAD_LIMIT || '1' },++ }));++ const parsed = parseTsv(stdout);++ return { ok: true, engine: 'tesseract', version: v, ms: Date.now() - started, ...parsed };++ } catch (err) {++ if (err && err.killed) return { ok: false, code: 'vision.ocr_timeout', detail: 'the local OCR engine ran past its time limit' };++ log.warn(`[OpenVibe.Vision] local OCR failed: ${String(err && err.message || err).slice(0, 200)}`);++ return { ok: false, code: 'vision.ocr_failed', detail: 'the local OCR engine could not read that image' };++ }++ }++++ return { recognize, available, parseTsv, bin, lang };++}++++module.exports = { createOcr, parseTsv };nnew file mode 100644@@@ -0,0 +1,208 @@++'use strict';++++/**++ * The v1 engine chooser. Cheapest first, as the plan says: the local OCR reader answers a read task when it can;++ * otherwise OpenVibe.AI's routed vision model answers. Every result carries the engine/model that actually++ * answered, the reason it was chosen, the measured latency and the cost AI reported (0 for local).++ *++ * compare runs the same image through the local reader and one or more AI hints side by side, records each++ * sample and stores the aggregate. A model hint is never reported as the answerer: AI's provenance wins.++ */++const { AiError } = require('./ai');++const { forModel } = require('./images');++const { cleanAnswer } = require('../text/no-looks');++++class VisionError extends Error {++ constructor(status, code, detail, extra = {}) {++ super(detail || code);++ this.name = 'VisionError';++ this.status = status;++ this.code = code;++ this.detail = detail || code;++ this.extra = extra;++ }++}++++const TASKS = new Set(['ask', 'describe', 'identify', 'count', 'translate', 'ocr', 'compare']);++const READ_RE = /\b(read|say|says|said|text|word|words|label|labels|sign|signs|receipt|receipts|handwriting|ocr|written|print|printed|translate|translation|caption|menu|address|number)\b/i;++++function isReadTask(task, question) {++ if (task === 'ocr') return true;++ // Local OCR can read the words, but it cannot translate them: a translate task (or a question that asks for a++ // translation) goes to the model when auto mode is on. mode=local still returns the reading, and says so.++ if (task === 'translate') return false;++ if (/\btranslate|translation\b/i.test(String(question || ''))) return false;++ return task === 'ask' && READ_RE.test(String(question || ''));++}++++function asTask(value) {++ const t = String(value || 'ask').toLowerCase();++ return TASKS.has(t) ? t : null;++}++++function createVision({ config, s, images, ocr, ai, store, log = console }) {++ const maxAiSamples = 3;++++ function localResult(image, task, { question = null, lang = null, startedAt = Date.now() } = {}) {++ return ocr.recognize(image.buffer, { lang }).then((r) => ({++ engine: 'local',++ ok: Boolean(r.ok && String(r.text || '').trim()),++ text: r.ok ? String(r.text || '').trim() : null,++ model: r.ok ? 'tesseract' : null,++ provider: 'local',++ route: 'local.ocr',++ reason: r.ok ? 'the local OCR reader answered the read task; no model was called' : null,++ costUsd: 0,++ ms: Number(r.ms) || (Date.now() - startedAt),++ errorCode: r.ok ? null : r.code,++ detail: r.ok ? null : r.detail,++ confidence: r.ok ? r.confidence : null,++ words: r.ok ? r.words : [],++ version: r.ok ? r.version : null,++ question,++ }));++ }++++ async function aiResult(image, task, { question = null, model = null } = {}) {++ const normal = await forModel(image.buffer);++ const started = Date.now();++ try {++ const a = await ai.answer({ dataUrl: normal.dataUrl, task, question, model });++ return {++ engine: 'ai', ok: true, text: a.text, model: a.model, provider: a.provider, route: a.route,++ reason: a.reason, costUsd: a.costUsd, ms: a.ms || (Date.now() - started), errorCode: null,++ detail: null, runId: a.runId, synthetic: a.synthetic, usage: a.usage,++ };++ } catch (err) {++ const code = (err && err.code) || 'vision.ai_failed';++ const detail = (err && (err.detail || err.message)) || 'OpenVibe.AI did not answer';++ return { engine: 'ai', ok: false, text: null, model: model || null, provider: null, route: null, reason: null, costUsd: 0, ms: Date.now() - started, errorCode: code, detail: String(detail).slice(0, 500) };++ }++ }++++ /**++ * ask: one image, one question. `mode`:++ * auto (default) local OCR for a read task when it finds text, else AI++ * local local OCR only; never a model++ * ai AI directly (the caller knows it needs a model), OCR task still prompts a read++ */++ async function ask({ requester, subject = null, image, task = 'ask', question = '', mode = 'auto', lang = null, model = null }) {++ const t = asTask(task);++ if (!t || t === 'compare') throw new VisionError(400, 'vision.invalid_task', 'task must be ask, describe, identify, count, translate or ocr');++ if (mode !== 'auto' && mode !== 'local' && mode !== 'ai') throw new VisionError(400, 'vision.invalid_mode', 'mode must be auto, local or ai');++ const started = Date.now();++ const q = String(question || '').trim().slice(0, 2000);++ if (t !== 'describe' && t !== 'ocr' && !q && mode !== 'local') throw new VisionError(400, 'vision.question_required', 'ask, identify, count and translate need a question');++++ let local = null;++ if (mode !== 'ai' && (isReadTask(t, q) || mode === 'local')) {++ local = await localResult(image, t, { question: q, lang, startedAt: started });++ } else if (mode === 'local' && t !== 'ocr') {++ local = await localResult(image, t, { question: q, lang, startedAt: started });++ }++++ let chosen = null;++ if (mode === 'local' || (local && local.ok && mode === 'auto')) {++ chosen = local || await localResult(image, t, { question: q, lang, startedAt: started });++ if (!chosen.ok && mode === 'local') throw new VisionError(502, chosen.errorCode || 'vision.ocr_failed', chosen.detail || 'the local OCR engine could not read that image');++ } else {++ if (!ai.enabled) {++ if (local && local.ok) chosen = local;++ else throw new VisionError(503, 'vision.ai_unavailable', 'OpenVibe.AI is not configured and the local OCR reader found no text for this task');++ } else {++ chosen = await aiResult(image, t, { question: q, model });++ if (!chosen.ok) {++ if (local && local.ok) chosen = local;++ else throw new VisionError(chosen.errorCode === 'vision.ai_unavailable' ? 503 : 502, chosen.errorCode || 'vision.ai_failed', chosen.detail || 'OpenVibe.AI did not answer');++ }++ }++ }++++ const latency = Date.now() - started;++ const answer = {++ engine: chosen.engine, model: chosen.model, provider: chosen.provider, route: chosen.route,++ reason: chosen.reason || (chosen.engine === 'local' ? 'the local OCR reader was the cheapest engine that could answer' : 'OpenVibe.AI routed the task to the model that answered'),++ confidence: chosen.confidence == null ? null : chosen.confidence,++ ...(chosen.words && chosen.words.length ? { words: chosen.words.slice(0, 200) } : {}),++ ...(chosen.runId ? { run_id: chosen.runId } : {}),++ ...(chosen.usage ? { usage: chosen.usage } : {}),++ };++ const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);++ const saved = await store.saveAnswer({++ subject, requester, task: t, question: q || null, mode, image,++ text, answer, model: chosen.model, provider: chosen.provider, route: chosen.route, reason: answer.reason,++ costUsd: chosen.costUsd, latencyMs: latency,++ });++ return { ...saved, engine: chosen.engine, confidence: chosen.confidence == null ? null : chosen.confidence };++ }++++ /** OCR: local first; `fallback: true` asks AI only when the local reader found nothing usable. */++ async function ocrImage({ requester, subject = null, image, lang = null, fallback = false, model = null }) {++ const started = Date.now();++ const local = await localResult(image, 'ocr', { lang, startedAt: started });++ let chosen = local;++ if ((!local.ok || !String(local.text || '').trim()) && fallback) {++ if (ai.enabled) chosen = await aiResult(image, 'ocr', { question: 'Read all visible text verbatim.', model });++ if (!chosen.ok && local.ok) chosen = local;++ }++ if (!chosen.ok) {++ const status = chosen.errorCode === 'vision.ai_unavailable' || chosen.errorCode === 'vision.ocr_unavailable' ? 503 : 502;++ throw new VisionError(status, chosen.errorCode || 'vision.ocr_failed', chosen.detail || 'no local OCR text was found');++ }++ const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);++ const answer = {++ engine: chosen.engine, model: chosen.model, provider: chosen.provider, route: chosen.route,++ reason: chosen.reason || (chosen.engine === 'local' ? 'the local OCR reader answered first' : 'the local OCR reader found no text, so OpenVibe.AI was asked'),++ confidence: chosen.confidence == null ? null : chosen.confidence,++ ...(chosen.words && chosen.words.length ? { words: chosen.words.slice(0, 500) } : {}),++ ...(chosen.runId ? { run_id: chosen.runId } : {}),++ };++ const saved = await store.saveAnswer({++ subject, requester, task: 'ocr', question: null, mode: fallback ? 'auto' : 'local', image,++ text, answer, model: chosen.model, provider: chosen.provider, route: chosen.route, reason: answer.reason,++ costUsd: chosen.costUsd, latencyMs: Date.now() - started,++ });++ return { ...saved, engine: chosen.engine, confidence: chosen.confidence == null ? null : chosen.confidence };++ }++++ /**++ * compare: local OCR and AI runs on the same image. `models` are hints; when empty, the AI route's offered++ * models are read (up to three) and one AI run is made for each distinct hint. AI's provenance is recorded.++ */++ async function compare({ requester, subject = null, image, task = 'ask', question = '', models = [], lang = null }) {++ const t = asTask(task);++ if (!t || t === 'compare') throw new VisionError(400, 'vision.invalid_task', 'compare needs ask, describe, identify, count, translate or ocr');++ const started = Date.now();++ const q = String(question || '').trim().slice(0, 2000) || 'Read this image and describe what it shows.';++ const samples = [];++ const local = await localResult(image, t, { question: q, lang, startedAt: started });++ samples.push({ engine: 'local', requestedModel: 'tesseract', model: local.model, provider: local.provider, route: local.route, ok: local.ok, text: local.text, costUsd: local.costUsd, ms: local.ms, errorCode: local.errorCode });++++ if (ai.enabled) {++ let hints = (Array.isArray(models) ? models : []).map((m) => String(m || '').trim()).filter(Boolean).slice(0, maxAiSamples);++ if (!hints.length) {++ const offered = await ai.offeredModels();++ hints = (offered.length ? offered : [{ provider: 'ai', model: null }])++ .map((o) => (o.provider && o.provider !== 'ai' ? `${o.provider}:${o.model || ''}` : (o.model || 'openvibe')))++ .filter(Boolean).slice(0, maxAiSamples);++ }++ for (const hint of hints) {++ const a = await aiResult(image, t, { question: q, model: hint });++ samples.push({ engine: 'ai', requestedModel: hint, model: a.model, provider: a.provider, route: a.route, ok: a.ok, text: a.ok ? cleanAnswer(a.text) : null, costUsd: a.costUsd, ms: a.ms, errorCode: a.errorCode });++ }++ }++++ const saved = await store.saveCompare({ subject, requester, task: t, image, question: q, samples });++ return {++ ...saved.answer,++ engine: 'compare',++ compare: saved.samples.map((x) => ({ ...x, text: x.text == null ? null : (x.engine === 'local' ? x.text : cleanAnswer(x.text)) })),++ local: { ok: local.ok, model: local.model, confidence: local.confidence == null ? null : local.confidence, error_code: local.errorCode || null },++ reason: `${saved.samples.length} engines ran on the same image; local OCR is free and AI runs report their own cost and latency`,++ };++ }++++ return { ask, ocr: ocrImage, compare, isReadTask, TASKS };++}++++module.exports = { createVision, VisionError, isReadTask, asTask, TASKS };nnew file mode 100644@@@ -0,0 +1,674 @@++'use strict';++++/**++ * The UI check (plan §5.8 and §3.11): overflow, cut-off text, low contrast, overlap and layering faults.++ *++ * Two local passes, no browser, no model, no paid service:++ *++ * static-html parses the document and its inline CSS, estimates the boxes and computes WCAG contrast from the++ * colours. It is the pass a generated HTML page or a URL gets. JavaScript is not executed, and a++ * remote stylesheet is not fetched: those are stated as limits in `warnings`.++ * screenshot reads a PNG/JPEG screenshot with the local OCR boxes and sharp's pixels: text at an edge is++ * reported as cut off, and the contrast inside each word box is measured. Layering cannot be seen++ * from pixels, and the answer says so.++ *++ * Findings are data: { kind, severity, selector, detail, evidence }. The API is what Design's Arena calls.++ */++const sharp = require('sharp');++const { assertPublicUrl } = require('./images');++++const BLOCK = new Set(['html', 'body', 'div', 'p', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figure', 'figcaption', 'form', 'fieldset', 'legend', 'blockquote', 'pre', 'hr', 'address', 'details', 'summary', 'dialog', 'canvas', 'video', 'img']);++const INLINE = new Set(['span', 'a', 'b', 'strong', 'i', 'em', 'u', 'small', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'label', 'button', 'input', 'select', 'textarea', 'br', 'time', 'abbr', 'cite', 'q', 'mark', 'svg', 'path']);++const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);++const HIDDEN_TAGS = new Set(['script', 'style', 'template', 'head', 'title', 'meta', 'link', 'base', 'noscript']);++const NAMED = {++ transparent: [0, 0, 0, 0], white: [255, 255, 255, 1], black: [0, 0, 0, 1], red: [255, 0, 0, 1],++ green: [0, 128, 0, 1], blue: [0, 0, 255, 1], gray: [128, 128, 128, 1], grey: [128, 128, 128, 1],++ silver: [192, 192, 192, 1], maroon: [128, 0, 0, 1], olive: [128, 128, 0, 1], lime: [0, 255, 0, 1],++ aqua: [0, 255, 255, 1], teal: [0, 128, 128, 1], navy: [0, 0, 128, 1], fuchsia: [255, 0, 255, 1],++ purple: [128, 0, 128, 1], orange: [255, 165, 0, 1], yellow: [255, 255, 0, 1], pink: [255, 192, 203, 1],++ brown: [165, 42, 42, 1], cyan: [0, 255, 255, 1], magenta: [255, 0, 255, 1], gold: [255, 215, 0, 1],++ darkgray: [169, 169, 169, 1], darkgrey: [169, 169, 169, 1], dimgray: [105, 105, 105, 1], dimgrey: [105, 105, 105, 1],++ lightgray: [211, 211, 211, 1], lightgrey: [211, 211, 211, 1], whitesmoke: [245, 245, 245, 1], gainsboro: [220, 220, 220, 1],++};++const MAX_NODES = 6000;++++function decodeEntities(s) {++ return String(s == null ? '' : s)++ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))++ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))++ .replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')++ .replace(/"/gi, '"').replace(/'|'/gi, "'");++}++++function parseAttrs(s) {++ const out = {};++ const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;++ let m;++ while ((m = re.exec(s || ''))) {++ const k = m[1].toLowerCase();++ out[k] = decodeEntities(m[2] != null ? m[2] : (m[3] != null ? m[3] : (m[4] != null ? m[4] : '')));++ }++ return out;++}++++/** A forgiving HTML tree. Script/style/head content is not part of the visible tree; <style> text is collected. */++function parseHtml(html) {++ const root = { tag: '#document', attrs: {}, classes: [], children: [], text: '', parent: null };++ const stack = [root];++ const styles = [];++ const warnings = [];++ let nodes = 0;++ let i = 0;++ const s = String(html || '');++ const tagRe = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<!doctype[^>]*>|<![^>]*>|<\/([a-zA-Z][\w:-]*)\s*>|<([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;++ let m;++ const addText = (text) => {++ if (!text) return;++ const top = stack[stack.length - 1];++ if (!top || HIDDEN_TAGS.has(top.tag)) return;++ const clean = decodeEntities(text);++ if (clean.trim()) top.text = `${top.text || ''}${top.text ? ' ' : ''}${clean.replace(/\s+/g, ' ').trim()}`;++ };++ while ((m = tagRe.exec(s))) {++ addText(s.slice(i, m.index));++ i = tagRe.lastIndex;++ if (m[0].startsWith('<!--') || /^<!/.test(m[0])) continue;++ if (m[1]) {++ const tag = m[1].toLowerCase();++ for (let j = stack.length - 1; j > 0; j--) {++ if (stack[j].tag === tag) { stack.length = j; break; }++ }++ continue;++ }++ if (!m[2]) continue;++ const tag = m[2].toLowerCase();++ const attrs = parseAttrs(m[3] || '');++ if (tag === 'style') {++ const end = s.toLowerCase().indexOf('</style', tagRe.lastIndex);++ if (end >= 0) {++ styles.push(s.slice(tagRe.lastIndex, end));++ tagRe.lastIndex = end;++ i = end;++ }++ continue;++ }++ if (nodes++ > MAX_NODES) { warnings.push('the document has more than 6000 elements; the pass stopped early'); break; }++ const node = {++ tag, attrs, id: attrs.id || null,++ classes: String(attrs.class || '').split(/\s+/).filter(Boolean),++ children: [], text: '', parent: stack[stack.length - 1],++ };++ node.parent.children.push(node);++ if (HIDDEN_TAGS.has(tag)) { stack.push(node); continue; }++ if (VOID.has(tag) || m[4] === '/') continue;++ stack.push(node);++ }++ addText(s.slice(i));++ // A fragment (or a document without body) still gets the body box, so `body { … }` rules apply.++ if (!root.children.some((c) => c.tag === 'body')) {++ const body = { tag: 'body', attrs: {}, id: null, classes: [], children: root.children, text: '', parent: root };++ for (const c of body.children) c.parent = body;++ root.children = [body];++ }++ if (!styles.length) warnings.push('no inline <style> block: only inline style attributes were read');++ warnings.push('JavaScript was not executed and remote stylesheets were not fetched; this is a static layout pass');++ return { root, styles, warnings };++}++++function parseColor(value) {++ const v = String(value == null ? '' : value).trim().toLowerCase();++ if (!v || v === 'currentcolor' || v === 'inherit' || v === 'initial' || v === 'unset') return null;++ if (NAMED[v]) return NAMED[v].slice();++ if (v === 'transparent') return [0, 0, 0, 0];++ let m = /^#([0-9a-f]{3,8})$/i.exec(v);++ if (m) {++ let h = m[1];++ if (h.length === 3) h = h.split('').map((c) => c + c).join('');++ if (h.length === 4) h = h.split('').map((c) => c + c).join('');++ if (h.length === 6 || h.length === 8) {++ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1];++ }++ }++ m = /^rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(v);++ if (m) {++ const n = (x, max) => (String(x).endsWith('%') ? Math.round(parseFloat(x) * max / 100) : parseFloat(x));++ const a = m[4] == null ? 1 : (String(m[4]).endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]));++ return [n(m[1], 255), n(m[2], 255), n(m[3], 255), Math.max(0, Math.min(1, Number.isFinite(a) ? a : 1))];++ }++ m = /^hsla?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(v);++ if (m) {++ const h = ((parseFloat(m[1]) % 360) + 360) % 360 / 360;++ const sat = parseFloat(m[2]) / 100; const li = parseFloat(m[3]) / 100;++ const q = li < 0.5 ? li * (1 + sat) : li + sat - li * sat;++ const p = 2 * li - q;++ const hue = (t) => { let x = t; if (x < 0) x += 1; if (x > 1) x -= 1; if (x < 1 / 6) return p + (q - p) * 6 * x; if (x < 1 / 2) return q; if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; return p; };++ const a = m[4] == null ? 1 : (String(m[4]).endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]));++ return [Math.round(hue(h + 1 / 3) * 255), Math.round(hue(h) * 255), Math.round(hue(h - 1 / 3) * 255), a];++ }++ return null;++}++++const colorCss = (c) => (c ? `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${Math.round(c[3] * 100) / 100})` : 'unknown');++const luminance = (c) => {++ const f = (n) => { const x = n / 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); };++ return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);++};++function contrastRatio(fg, bg) {++ const a = fg[3] == null ? 1 : fg[3];++ const blended = [0, 1, 2].map((i) => Math.round(fg[i] * a + bg[i] * (1 - a)));++ const l1 = luminance(blended); const l2 = luminance(bg);++ const hi = Math.max(l1, l2); const lo = Math.min(l1, l2);++ return (hi + 0.05) / (lo + 0.05);++}++++function parseLength(value, { fontSize = 16, parent = 16, root = 16, percentBase = 0 } = {}) {++ const v = String(value == null ? '' : value).trim().toLowerCase();++ if (!v || v === 'auto' || v === 'none' || v === 'normal') return null;++ if (v.endsWith('px')) return parseFloat(v);++ if (v.endsWith('rem')) return parseFloat(v) * root;++ if (v.endsWith('em')) return parseFloat(v) * parent;++ if (v.endsWith('%')) return parseFloat(v) * percentBase / 100;++ if (v.endsWith('vw')) return parseFloat(v) * percentBase / 100;++ if (v.endsWith('vh')) return parseFloat(v) * percentBase / 100;++ if (v.endsWith('pt')) return parseFloat(v) * 4 / 3;++ if (/^[\d.]+$/.test(v)) return parseFloat(v);++ return null;++}++++function parseDeclarations(text) {++ const out = {};++ for (const raw of String(text || '').split(';')) {++ const i = raw.indexOf(':');++ if (i < 0) continue;++ const k = raw.slice(0, i).trim().toLowerCase();++ const v = raw.slice(i + 1).trim();++ if (k && v) out[k] = v.replace(/\s*!important\s*$/i, '');++ }++ if (out.background && !out['background-color']) {++ const simple = String(out.background).trim();++ if (parseColor(simple)) out['background-color'] = simple;++ else if (/^#|^rgb|^hsl/i.test(simple) && !/gradient|url\(/i.test(simple)) out['background-color'] = simple.split(/\s+/)[0];++ out['background-image'] = /gradient|url\(/i.test(simple) ? simple : out['background-image'];++ }++ if (out.margin) { const p = out.margin.split(/\s+/); out['margin-top'] = p[0]; out['margin-right'] = p[1] || p[0]; out['margin-bottom'] = p[2] || p[0]; out['margin-left'] = p[3] || p[1] || p[0]; }++ if (out.padding) { const p = out.padding.split(/\s+/); out['padding-top'] = p[0]; out['padding-right'] = p[1] || p[0]; out['padding-bottom'] = p[2] || p[0]; out['padding-left'] = p[3] || p[1] || p[0]; }++ if (out.inset) { const p = out.inset.split(/\s+/); out.top = p[0]; out.right = p[1] || p[0]; out.bottom = p[2] || p[0]; out.left = p[3] || p[1] || p[0]; }++ return out;++}++++function mediaMatches(condition, viewport) {++ const c = String(condition || '').toLowerCase();++ if (!c || /\ball\b|\bscreen\b/.test(c)) {++ const max = /max-width\s*:\s*([\d.]+)px/.exec(c); const min = /min-width\s*:\s*([\d.]+)px/.exec(c);++ if (max && viewport.w > parseFloat(max[1])) return false;++ if (min && viewport.w < parseFloat(min[1])) return false;++ return true;++ }++ return false;++}++++/** The inline stylesheets' rules, in source order. @media is applied only when the viewport matches. */++function parseRules(css, viewport) {++ const rules = [];++ const warnings = [];++ let order = 0;++ const text = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');++ const parseBlock = (src) => {++ let i = 0;++ while (i < src.length) {++ const open = src.indexOf('{', i);++ if (open < 0) break;++ const selector = src.slice(i, open).trim();++ let depth = 1; let j = open + 1;++ while (j < src.length && depth) { if (src[j] === '{') depth++; else if (src[j] === '}') depth--; j++; }++ const body = src.slice(open + 1, j - 1);++ if (/^@media\b/i.test(selector)) {++ const cond = selector.replace(/^@media\s*/i, '');++ if (mediaMatches(cond, viewport)) parseBlock(body);++ } else if (/^@supports\b/i.test(selector)) {++ parseBlock(body);++ } else if (selector.startsWith('@')) {++ // @font-face, @keyframes and @page carry no layout we can judge.++ } else {++ const declarations = parseDeclarations(body);++ order++;++ for (const one of selector.split(',')) {++ const sel = one.trim();++ if (!sel || /::|:(?:hover|focus|active|visited|before|after|placeholder|checked|disabled)/i.test(sel)) continue;++ rules.push({ selector: sel, declarations, order, specificity: specificity(sel) });++ }++ }++ i = j;++ }++ };++ parseBlock(text);++ if (rules.length === 0) warnings.push('no usable CSS rules were found');++ return { rules, warnings };++}++++function specificity(selector) {++ const s = String(selector).replace(/:root\b/g, 'html');++ const ids = (s.match(/#[\w-]+/g) || []).length;++ const cls = (s.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+/g) || []).length;++ const tags = (s.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;++ return ids * 10000 + cls * 100 + tags;++}++++function matchCompound(node, compound) {++ if (!node || !node.tag || node.tag === '#document') return false;++ let c = String(compound).trim();++ if (!c) return false;++ if (c === '*') return true;++ c = c.replace(/:root\b/g, 'html').replace(/:(?:first|last|nth|only)-[a-z-]+(?:\([^)]*\))?/gi, '');++ const tag = /^([a-zA-Z][\w-]*)/.exec(c);++ if (tag && tag[1].toLowerCase() !== node.tag) return false;++ for (const m of c.matchAll(/#([\w-]+)/g)) if (node.id !== m[1]) return false;++ for (const m of c.matchAll(/\.([\w-]+)/g)) if (!node.classes.includes(m[1])) return false;++ for (const m of c.matchAll(/\[([\w-]+)(?:=["']?([^"'\]]+)["']?)?\]/g)) {++ const v = node.attrs[m[1].toLowerCase()];++ if (v == null) return false;++ if (m[2] != null && String(v) !== m[2]) return false;++ }++ return true;++}++++function matches(node, selector) {++ const tokens = String(selector).replace(/>/g, ' > ').trim().split(/\s+/);++ let i = tokens.length - 1;++ let n = node;++ while (i >= 0) {++ if (tokens[i] === '>') { i--; n = n && n.parent; if (!n) return false; continue; }++ if (!matchCompound(n, tokens[i])) return false;++ i--;++ if (i < 0) return true;++ if (tokens[i] === '>') { i--; n = n.parent; if (!n) return false; continue; }++ const next = tokens[i];++ let a = n.parent;++ while (a && !matchCompound(a, next)) a = a.parent;++ if (!a) return false;++ n = a; i--;++ }++ return true;++}++++function initialDisplay(tag) { return BLOCK.has(tag) ? 'block' : (INLINE.has(tag) ? 'inline' : 'block'); }++++function createComputed(root, rules, viewport) {++ const rootStyle = {++ color: [0, 0, 0, 1], 'background-color': 'transparent', 'background-image': null, 'font-size': 16, 'font-weight': 400,++ 'line-height': 1.25, 'white-space': 'normal', 'text-align': 'left', display: 'block', position: 'static',++ overflow: 'visible', 'overflow-x': 'visible', 'overflow-y': 'visible', width: 'auto', height: 'auto',++ 'max-width': 'none', 'min-width': '0', margin: [0, 0, 0, 0], padding: [0, 0, 0, 0],++ top: null, right: null, bottom: null, left: null, 'z-index': null, 'box-sizing': 'content-box',++ opacity: 1, 'text-transform': 'none', 'letter-spacing': 0, 'word-break': 'normal', 'border-left-width': 0, 'border-right-width': 0,++ };++ const inherited = ['color', 'font-size', 'font-weight', 'line-height', 'white-space', 'text-align', 'text-transform', 'letter-spacing', 'word-break', 'font-family'];++ const apply = (st, decls, parent) => {++ for (const [k, v] of Object.entries(decls)) {++ if (k === 'display') st.display = v;++ else if (k === 'position') st.position = v;++ else if (k === 'color') { const c = parseColor(v); if (c) st.color = c; }++ else if (k === 'background-color') { const c = parseColor(v); if (c) st['background-color'] = c; }++ else if (k === 'background-image') st['background-image'] = v;++ else if (k === 'font-size') { const n = parseLength(v, { parent: parent['font-size'], root: 16, percentBase: parent['font-size'] }); if (n != null) st['font-size'] = Math.max(0, n); }++ else if (k === 'font-weight') { st['font-weight'] = v === 'bold' ? 700 : v === 'normal' ? 400 : (parseInt(v, 10) || 400); }++ else if (k === 'line-height') { const n = parseLength(v, { parent: st['font-size'], percentBase: st['font-size'] }); st['line-height'] = n == null ? v : n; }++ else if (k === 'white-space') st['white-space'] = v;++ else if (k === 'text-align') st['text-align'] = v;++ else if (k === 'text-transform') st['text-transform'] = v;++ else if (k === 'letter-spacing') { const n = parseLength(v, { parent: st['font-size'], percentBase: st['font-size'] }); if (n != null) st['letter-spacing'] = n; }++ else if (k === 'word-break' || k === 'overflow-wrap') st['word-break'] = v;++ else if (k === 'width' || k === 'height' || k === 'max-width' || k === 'min-width') st[k] = v;++ else if (k === 'overflow') { st.overflow = v; st['overflow-x'] = v; st['overflow-y'] = v; }++ else if (k === 'overflow-x') { st.overflow = v; st['overflow-x'] = v; }++ else if (k === 'overflow-y') { st.overflow = v; st['overflow-y'] = v; }++ else if (/^margin-(top|right|bottom|left)$/.test(k)) { const idx = { 'margin-top': 0, 'margin-right': 1, 'margin-bottom': 2, 'margin-left': 3 }[k]; const n = parseLength(v, { parent: st['font-size'], percentBase: viewport.w }); st.margin[idx] = n == null ? 0 : n; }++ else if (/^padding-(top|right|bottom|left)$/.test(k)) { const idx = { 'padding-top': 0, 'padding-right': 1, 'padding-bottom': 2, 'padding-left': 3 }[k]; const n = parseLength(v, { parent: st['font-size'], percentBase: viewport.w }); st.padding[idx] = n == null ? 0 : n; }++ else if (['top', 'right', 'bottom', 'left'].includes(k)) st[k] = v;++ else if (k === 'z-index') st['z-index'] = v === 'auto' ? null : parseInt(v, 10);++ else if (k === 'box-sizing') st['box-sizing'] = v;++ else if (k === 'opacity') { const n = parseFloat(v); st.opacity = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 1; }++ else if (k === 'border-left-width') st['border-left-width'] = parseLength(v) || 0;++ else if (k === 'border-right-width') st['border-right-width'] = parseLength(v) || 0;++ else if (k === 'hidden') st.display = 'none';++ }++ };++ const walk = (node, parent, depth) => {++ if (depth > 80) return;++ const st = {};++ for (const k of inherited) st[k] = parent ? parent[k] : rootStyle[k];++ for (const k of ['color', 'background-color', 'background-image', 'font-size', 'font-weight', 'line-height', 'white-space', 'text-align', 'display', 'position', 'overflow', 'overflow-x', 'overflow-y', 'width', 'height', 'max-width', 'min-width', 'top', 'right', 'bottom', 'left', 'z-index', 'box-sizing', 'opacity', 'text-transform', 'letter-spacing', 'word-break', 'border-left-width', 'border-right-width']) {++ st[k] = parent ? parent[k] : rootStyle[k];++ }++ st.margin = parent ? parent.margin.slice() : [0, 0, 0, 0];++ st.padding = parent ? parent.padding.slice() : [0, 0, 0, 0];++ st.color = parent ? parent.color : rootStyle.color;++ st['background-color'] = [0, 0, 0, 0];++ st['background-image'] = null;++ st.display = initialDisplay(node.tag);++ st.position = 'static'; st.overflow = 'visible'; st['overflow-x'] = 'visible'; st['overflow-y'] = 'visible';++ st.width = 'auto'; st.height = 'auto'; st['max-width'] = 'none'; st['min-width'] = '0';++ st.top = null; st.right = null; st.bottom = null; st.left = null; st['z-index'] = null;++ st['box-sizing'] = 'content-box'; st.opacity = parent ? parent.opacity : 1; st['border-left-width'] = 0; st['border-right-width'] = 0;++ st.margin = [0, 0, 0, 0]; st.padding = [0, 0, 0, 0];++ st['font-size'] = parent ? parent['font-size'] : 16;++ st['font-weight'] = parent ? parent['font-weight'] : 400;++ st['line-height'] = parent ? parent['line-height'] : 1.25;++ st['white-space'] = parent ? parent['white-space'] : 'normal';++ st['text-align'] = parent ? parent['text-align'] : 'left';++ st['text-transform'] = parent ? parent['text-transform'] : 'none';++ st['letter-spacing'] = parent ? parent['letter-spacing'] : 0;++ st['word-break'] = parent ? parent['word-break'] : 'normal';++ if (node.attrs && (node.attrs.hidden != null)) st.display = 'none';++ const applicable = rules.filter((r) => matches(node, r.selector)).sort((a, b) => a.specificity - b.specificity || a.order - b.order);++ for (const r of applicable) apply(st, r.declarations, parent || rootStyle);++ if (node.attrs && node.attrs.style) apply(st, parseDeclarations(node.attrs.style), parent || rootStyle);++ node.style = st;++ for (const child of node.children || []) walk(child, st, depth + 1);++ };++ walk(root, null, 0);++}++++function selectorOf(node) {++ if (!node || !node.tag || node.tag === '#document') return ':root';++ const cls = (node.classes || []).slice(0, 2).map((c) => `.${c}`).join('');++ return `${node.tag}${node.id ? `#${node.id}` : ''}${cls}`;++}++++function textWidth(text, st) {++ const fs = Number(st['font-size']) || 16;++ const bold = Number(st['font-weight']) >= 600;++ const spacing = Number(st['letter-spacing']) || 0;++ let n = 0;++ for (const ch of String(text || '')) {++ if (ch === ' ') n += 0.28;++ else if (/[ilI.,'`!|]/.test(ch)) n += 0.3;++ else if (/[mwMW@#%&]/.test(ch)) n += 0.86;++ else if (/[A-Z]/.test(ch)) n += 0.64;++ else n += 0.53;++ }++ return n * fs + Math.max(0, String(text || '').length - 1) * spacing;++}++++function num(st, key) {++ const n = parseLength(st[key], { parent: st['font-size'], percentBase: st['font-size'] });++ return n == null ? 0 : n;++}++++function boxOf(node, parentBox, viewport) {++ const st = node.style;++ if (!st || st.display === 'none' || st.opacity === 0) return null;++ const parentW = parentBox ? parentBox.contentW : viewport.w;++ const parentX = parentBox ? parentBox.x : 0;++ const parentY = parentBox ? parentBox.y : 0;++ const pad = st.padding || [0, 0, 0, 0];++ const mar = st.margin || [0, 0, 0, 0];++ const border = (Number(st['border-left-width']) || 0) + (Number(st['border-right-width']) || 0);++ const outerAvail = Math.max(0, parentW - mar[1] - mar[3]);++ const available = Math.max(0, outerAvail - pad[1] - pad[3] - border);++ let width;++ const wv = String(st.width || 'auto');++ if (wv === 'auto') width = (st.display === 'inline' || st.display === 'inline-block') ? Math.min(available, textWidth(node.text, st) + pad[1] + pad[3]) : available;++ else if (wv.endsWith('vw')) width = parseFloat(wv) * viewport.w / 100;++ else if (wv.endsWith('%')) width = outerAvail * parseFloat(wv) / 100;++ else width = parseLength(wv, { parent: st['font-size'], percentBase: outerAvail }) || available;++ const maxw = String(st['max-width'] || 'none');++ if (maxw !== 'none' && maxw !== 'auto') {++ const m = maxw.endsWith('%') ? outerAvail * parseFloat(maxw) / 100 : (maxw.endsWith('px') ? parseFloat(maxw) : null);++ if (m != null) width = Math.min(width, m);++ }++ const minw = String(st['min-width'] || '0');++ if (minw !== '0' && minw !== 'auto') {++ const m = minw.endsWith('%') ? outerAvail * parseFloat(minw) / 100 : (minw.endsWith('px') ? parseFloat(minw) : null);++ if (m != null) width = Math.max(width, m);++ }++ if (st['box-sizing'] === 'border-box') width = Math.max(0, width - pad[1] - pad[3] - border);++ const fs = Number(st['font-size']) || 16;++ const lh = typeof st['line-height'] === 'number' ? st['line-height'] : (parseFloat(st['line-height']) || 1.25);++ const text = String(node.text || '');++ const tw = textWidth(text, st);++ const nowrap = /nowrap|pre/.test(String(st['white-space']));++ const lines = Math.max(1, nowrap ? 1 : Math.ceil(tw / Math.max(1, width)));++ const autoH = Math.max(fs * lh, lines * fs * lh) + pad[0] + pad[2];++ let height = String(st.height || 'auto') === 'auto' ? autoH : (parseLength(st.height, { parent: fs, percentBase: (parentBox ? parentBox.height : viewport.h) }) || autoH);++ let x = parentX + mar[3] + pad[3];++ let y = parentY + mar[0] + pad[0];++ const positioned = st.position === 'absolute' || st.position === 'fixed';++ const fixed = st.position === 'fixed';++ const cbW = fixed ? viewport.w : parentW;++ const cbH = fixed ? viewport.h : (parentBox ? parentBox.height : viewport.h);++ const px = (v, base) => (v == null ? null : (parseLength(v, { parent: st['font-size'], percentBase: base }) || 0));++ const pTop = px(st.top, cbH); const pBottom = px(st.bottom, cbH);++ const pLeft = px(st.left, cbW); const pRight = px(st.right, cbW);++ if (positioned && String(st.height || 'auto') === 'auto' && pTop != null && pBottom != null) height = Math.max(0, cbH - pTop - pBottom - pad[0] - pad[2]);++ if (positioned && String(st.width || 'auto') === 'auto' && pLeft != null && pRight != null) width = Math.max(0, cbW - pLeft - pRight - pad[1] - pad[3]);++ if (positioned) {++ const baseX = fixed ? 0 : parentX; const baseY = fixed ? 0 : parentY;++ if (pLeft != null) x = baseX + pLeft;++ else if (pRight != null) x = baseX + cbW - pRight - width;++ if (pTop != null) y = baseY + pTop;++ else if (pBottom != null) y = baseY + cbH - pBottom - height;++ }++ return { x, y, width, height, contentW: width, right: x + width + pad[1], bottom: y + height + pad[2], textWidth: tw, lines, nowrap };++}++++function analyze(root, { viewport, maxFindings = 200 } = {}) {++ const findings = [];++ const add = (kind, severity, node, detail, evidence) => {++ if (findings.length >= maxFindings) return;++ findings.push({ kind, severity, selector: selectorOf(node), detail, evidence });++ };++ const boxes = [];++ const positioned = [];++ const walk = (node, parentBox, depth) => {++ if (depth > 80 || findings.length >= maxFindings) return;++ if (!node.tag || node.tag === '#document') {++ for (const c of node.children || []) walk(c, parentBox, depth + 1);++ return;++ }++ const st = node.style;++ if (!st || st.display === 'none' || st.opacity === 0) return;++ const box = boxOf(node, parentBox, viewport);++ if (!box) return;++ node.box = box;++ boxes.push({ node, box });++ if (st.position === 'absolute' || st.position === 'fixed') positioned.push({ node, box });++ const text = String(node.text || '').trim();++ if (text) {++ // Boxes wider than the viewport, or content that does not fit the box it was given.++ if (box.width > viewport.w + 1 || box.right > viewport.w + 1) {++ add('overflow', 'medium', node, `the box is ${Math.round(box.width)}px wide and reaches ${Math.round(box.right)}px in a ${viewport.w}px viewport`, { width: Math.round(box.width), right: Math.round(box.right), viewport: viewport.w });++ }++ if (box.x < -1 || box.y < -1) {++ add('overflow', 'high', node, `the box starts outside the viewport at ${Math.round(box.x)}, ${Math.round(box.y)}`, { x: Math.round(box.x), y: Math.round(box.y) });++ }++ const hiddenX = /hidden|clip|scroll|auto/.test(String(st['overflow-x'] || st.overflow));++ if (hiddenX && box.textWidth > box.width + 2 && box.nowrap) {++ add('cut_off', 'high', node, `nowrap text is about ${Math.round(box.textWidth)}px wide inside a ${Math.round(box.width)}px box with overflow ${st['overflow-x'] || st.overflow}`, { text: text.slice(0, 120), textWidth: Math.round(box.textWidth), boxWidth: Math.round(box.width), overflow: st['overflow-x'] || st.overflow });++ }++ const explicitH = String(st.height || 'auto') !== 'auto' ? parseLength(st.height, { parent: st['font-size'], percentBase: (parentBox ? parentBox.height : viewport.h) }) : null;++ if (explicitH != null && box.lines * (Number(st['font-size']) || 16) * (typeof st['line-height'] === 'number' ? st['line-height'] : 1.25) > explicitH + 2 && /hidden|clip/.test(String(st.overflow))) {++ add('cut_off', 'medium', node, 'the text needs more lines than the fixed height allows and overflow is hidden', { lines: box.lines, height: Math.round(explicitH), overflow: st.overflow });++ }++ if (!hiddenX && box.nowrap && box.textWidth > viewport.w + 1) {++ add('overflow', 'medium', node, `nowrap text is about ${Math.round(box.textWidth)}px wide in a ${viewport.w}px viewport and can scroll the page sideways`, { text: text.slice(0, 120), textWidth: Math.round(box.textWidth), viewport: viewport.w });++ }++ // Contrast against the nearest painted background.++ if (!st['background-image']) {++ const layers = [];++ let bgNode = node; let unknown = false; let base = [255, 255, 255, 1];++ while (bgNode) {++ const s2 = bgNode.style || {};++ if (s2['background-image']) { unknown = true; break; }++ const c = s2['background-color'];++ if (c && c[3] > 0) {++ layers.push(c);++ if (c[3] >= 0.999) { base = c; break; }++ }++ bgNode = bgNode.parent;++ }++ // Composite the translucent layers from the outermost to the innermost over the opaque base.++ let bg = base;++ for (let i = layers.length - 1; i >= 0; i--) {++ const l = layers[i];++ bg = [0, 1, 2].map((k) => Math.round(l[k] * l[3] + bg[k] * (1 - l[3]))).concat(1);++ }++ if (!unknown) {++ const fs = Number(st['font-size']) || 16;++ const bold = Number(st['font-weight']) >= 700;++ const large = fs >= 24 || (fs >= 18.66 && bold);++ const required = large ? 3 : 4.5;++ const ratio = contrastRatio(st.color, bg);++ if (ratio + 0.01 < required) {++ add('contrast', ratio < 3 ? 'high' : 'medium', node, `text contrast is ${ratio.toFixed(2)}:1 (needs ${required}:1 for ${large ? 'large' : 'body'} text)`, { ratio: Math.round(ratio * 100) / 100, required, color: colorCss(st.color), background: colorCss(bg), fontSize: fs, large });++ }++ }++ }++ }++ // Layering faults that CSS itself states.++ const z = st['z-index'];++ if (z != null && (st.position === 'static' || !st.position)) {++ add('layering', 'low', node, `z-index ${z} has no effect while position is static`, { zIndex: z, position: st.position });++ }++ if (z != null && z < 0) {++ add('layering', 'medium', node, `a negative z-index (${z}) can put this box behind its parent's background`, { zIndex: z });++ }++ if (st.position === 'fixed' && box.width >= viewport.w * 0.9 && box.height >= viewport.h * 0.9 && (z == null || z >= 0)) {++ add('layering', 'medium', node, 'a fixed box covers at least 90% of the viewport; check that it is intended and dismissible', { width: Math.round(box.width), height: Math.round(box.height), zIndex: z });++ }++ for (const c of node.children || []) walk(c, box, depth + 1);++ };++ walk(root, null, 0);++++ // Overlap between absolutely/fixed positioned boxes that are not ancestors of each other.++ for (let i = 0; i < positioned.length && findings.length < maxFindings; i++) {++ for (let j = i + 1; j < positioned.length && findings.length < maxFindings; j++) {++ const A = positioned[i]; const B = positioned[j];++ let a = A.node; let b = B.node; let related = false;++ while (a) { if (a === B.node) { related = true; break; } a = a.parent; }++ while (b && !related) { if (b === A.node) { related = true; break; } b = b.parent; }++ if (related) continue;++ const ix = Math.max(0, Math.min(A.box.right, B.box.right) - Math.max(A.box.x, B.box.x));++ const iy = Math.max(0, Math.min(A.box.bottom, B.box.bottom) - Math.max(A.box.y, B.box.y));++ const area = ix * iy;++ const smaller = Math.min(Math.max(1, A.box.width * A.box.height), Math.max(1, B.box.width * B.box.height));++ if (area > smaller * 0.25) {++ add('overlap', 'medium', A.node, `this positioned box overlaps ${selectorOf(B.node)} by ${Math.round(area / smaller * 100)}% of the smaller box`, { with: selectorOf(B.node), overlapPct: Math.round(area / smaller * 100) });++ }++ }++ }++ return findings;++}++++function summarize(findings, { engine, viewport, source, url = null, warnings = [] } = {}) {++ const counts = {};++ const severity = { high: 0, medium: 0, low: 0 };++ for (const f of findings) { counts[f.kind] = (counts[f.kind] || 0) + 1; severity[f.severity] = (severity[f.severity] || 0) + 1; }++ return {++ ok: findings.length === 0,++ engine, source, ...(url ? { url } : {}), viewport,++ counts, severity, findings: findings.length,++ summary: findings.length++ ? `${findings.length} UI ${findings.length === 1 ? 'fault' : 'faults'} found (${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')})`++ : 'No overflow, cut-off text, low contrast, overlap or layering fault was found by this pass.',++ warnings: [...new Set(warnings)].slice(0, 8),++ };++}++++function checkHtml(html, { viewport = { w: 1280, h: 800 }, maxFindings = 200, source = 'html', url = null } = {}) {++ const parsed = parseHtml(html);++ const { rules, warnings } = parseRules(parsed.styles.join('\n'), viewport);++ createComputed(parsed.root, rules, viewport);++ const findings = analyze(parsed.root, { viewport, maxFindings });++ return { ...summarize(findings, { engine: 'static-html', viewport, source, url, warnings: [...parsed.warnings, ...warnings] }), findings };++}++++async function fetchHtml(rawUrl, { config, fetchImpl = globalThis.fetch }) {++ const maxBytes = (config && config.ui && config.ui.maxHtmlBytes) || 1024 * 1024;++ const allowPrivate = Boolean(config && config.ui && config.ui.allowPrivate);++ const timeoutMs = (config && config.ui && config.ui.timeoutMs) || 10000;++ let url = await assertPublicUrl(rawUrl, { allowPrivate });++ for (let hop = 0; hop < 4; hop++) {++ let res;++ try { res = await fetchImpl(url.toString(), { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'text/html,application/xhtml+xml', 'User-Agent': 'OpenVibe.Vision/0.1 UI check' } }); }++ catch (err) { const e = new Error(err && err.name === 'TimeoutError' ? 'the page did not answer in time' : 'the page could not be reached'); e.code = 'ui.unreachable'; throw e; }++ if (res.status >= 300 && res.status < 400) {++ const loc = res.headers.get('location');++ if (!loc) { const e = new Error('the page redirected without a location'); e.code = 'ui.unreachable'; throw e; }++ url = await assertPublicUrl(new URL(loc, url).toString(), { allowPrivate });++ continue;++ }++ if (!res.ok) { const e = new Error(`the page answered ${res.status}`); e.code = 'ui.unreachable'; throw e; }++ const type = String(res.headers.get('content-type') || '').toLowerCase();++ const len = Number(res.headers.get('content-length'));++ if (Number.isFinite(len) && len > maxBytes) { const e = new Error('the page is larger than the UI check limit'); e.code = 'ui.too_large'; throw e; }++ const buf = Buffer.from(await res.arrayBuffer());++ if (buf.length > maxBytes) { const e = new Error('the page is larger than the UI check limit'); e.code = 'ui.too_large'; throw e; }++ const body = buf.toString('utf8');++ if (!/html|xhtml|text\/plain/.test(type) && !/^\s*<!doctype|^\s*<html/i.test(body)) { const e = new Error(`expected an HTML page, got ${type || 'an unknown type'}`); e.code = 'ui.not_html'; throw e; }++ return { html: body, url: url.toString() };++ }++ const e = new Error('too many redirects for that page'); e.code = 'ui.unreachable'; throw e;++}++++/** Screenshot pass: OCR boxes at an edge, and the contrast inside each word box. */++async function checkScreenshot(buffer, { viewport = { w: 1280, h: 800 }, ocr, maxFindings = 200 } = {}) {++ const meta = await sharp(buffer, { failOn: 'none' }).metadata();++ const width = Number(meta.width) || viewport.w;++ const height = Number(meta.height) || viewport.h;++ const findings = [];++ const warnings = ['a screenshot cannot show CSS layering or the DOM; the static-html pass does that'];++ if (ocr) {++ const read = await ocr.recognize(buffer).catch(() => null);++ if (read && read.ok) {++ const raw = await sharp(buffer, { failOn: 'none' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });++ const { data, info } = raw;++ for (const word of read.words || []) {++ if (findings.length >= maxFindings) break;++ const right = word.x + word.w; const bottom = word.y + word.h;++ if (right >= width - 2 || bottom >= height - 2 || word.x <= 1 || word.y <= 1) {++ findings.push({ kind: 'cut_off', severity: right >= width - 2 ? 'high' : 'medium', selector: `text:"${word.text.slice(0, 40)}"`, detail: `the word "${word.text.slice(0, 60)}" sits at the edge of the ${width}×${height} screenshot and may be cut off`, evidence: { x: word.x, y: word.y, w: word.w, h: word.h, width, height } });++ }++ if (word.conf >= 40 && word.w > 3 && word.h > 3) {++ let min = 1; let max = 0;++ for (let y = Math.max(0, word.y); y < Math.min(info.height, word.y + word.h); y += Math.max(1, Math.floor(word.h / 12))) {++ for (let x = Math.max(0, word.x); x < Math.min(info.width, word.x + word.w); x += Math.max(1, Math.floor(word.w / 16))) {++ const i = (y * info.width + x) * info.channels;++ const lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;++ if (lum < min) min = lum;++ if (lum > max) max = lum;++ }++ }++ const ratio = (Math.max(min, max) + 0.05) / (Math.min(min, max) + 0.05);++ const fs = word.h;++ const required = fs >= 24 ? 3 : 4.5;++ if (max - min > 0.05 && ratio < required) {++ findings.push({ kind: 'contrast', severity: ratio < 3 ? 'high' : 'medium', selector: `text:"${word.text.slice(0, 40)}"`, detail: `the text "${word.text.slice(0, 60)}" measures about ${ratio.toFixed(2)}:1 against its background`, evidence: { ratio: Math.round(ratio * 100) / 100, required, x: word.x, y: word.y, w: word.w, h: word.h } });++ }++ }++ }++ if (!(read.words || []).length) warnings.push('the screenshot contained no readable text for the OCR boxes; only the image size was checked');++ } else if (read && !read.ok) {++ warnings.push(`the OCR pass did not run: ${read.detail || read.code}`);++ }++ } else {++ warnings.push('no local OCR engine was available for the screenshot text boxes');++ }++ const out = summarize(findings, { engine: 'screenshot', viewport: { w: width, h: height }, source: 'screenshot', warnings });++ return { ...out, findings };++}++++async function check(opts = {}) {++ const viewport = { w: Math.max(160, Math.min(10000, Number(opts.viewport && opts.viewport.w) || 1280)), h: Math.max(160, Math.min(10000, Number(opts.viewport && opts.viewport.h) || 800)) };++ const maxFindings = Math.min(500, Math.max(10, Number(opts.maxFindings) || 200));++ if (opts.imageBuffer) return checkScreenshot(opts.imageBuffer, { viewport, ocr: opts.ocr, maxFindings });++ if (opts.html) return checkHtml(opts.html, { viewport, maxFindings, source: 'html' });++ if (opts.url) {++ const fetched = await fetchHtml(opts.url, { config: opts.config, fetchImpl: opts.fetchImpl });++ return checkHtml(fetched.html, { viewport, maxFindings, source: 'url', url: fetched.url });++ }++ const e = new Error('send html, a URL or a screenshot image'); e.code = 'ui.input_required'; throw e;++}++++module.exports = { check, checkHtml, checkScreenshot, fetchHtml, parseHtml, parseRules, analyze, contrastRatio, parseColor, textWidth };nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * ADR-033: Vision's part of an account export (answers, compare samples, UI checks) and of a deletion, applied++ * through /internal/events with a stand-in Network. A deletion erases the person's rows and leaves other people's.++ * A redelivery changes nothing; a bad signature and a proxied request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = ids.newId('user');++const B = ids.newId('user');++const EXP = `exp_${ids.ulid()}`;++const DEL = `del_${ids.ulid()}`;++const SECRET = `fixture_${'account'.repeat(6)}`;++const WRONG = `fixture_${'mismatch'.repeat(6)}`;++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++const IMAGE = { source: 'upload', sourceUrl: null, sha256: 'a'.repeat(64), mime: 'image/png', bytes: 100 };++++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_vision', 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: 'vision', clientSecret: 'vision-secret' });++ const t = await boot({ env: { VISION_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const aAnswer = await t.ctx.store.saveAnswer({ subject: A, requester: `user:${A}`, task: 'ask', question: 'What is this?', image: IMAGE, text: 'A answer', answer: { engine: 'ai', model: 'm' }, model: 'm', provider: 'mock', route: 'default.chat', costUsd: 0.0004, latencyMs: 10 });++ const bAnswer = await t.ctx.store.saveAnswer({ subject: B, requester: `user:${B}`, task: 'ocr', image: IMAGE, text: 'B answer', answer: { engine: 'local' }, model: 'tesseract', provider: 'local', route: 'local.ocr' });++ const aCheck = await t.ctx.store.saveUiCheck({ subject: A, requester: `user:${A}`, source: 'html', viewport: { w: 390, h: 844 }, engine: 'static-html', findings: [{ kind: 'overflow' }], summary: { summary: 'one fault' } });++ const bCheck = await t.ctx.store.saveUiCheck({ subject: B, requester: `user:${B}`, source: 'url', url: 'https://openvibe.vision/', viewport: { w: 1280, h: 800 }, engine: 'static-html', findings: [], summary: {} });++ const aCompare = await t.ctx.store.saveCompare({ subject: A, requester: `user:${A}`, task: 'ask', image: IMAGE, question: 'compare', samples: [{ engine: 'local', model: 'tesseract', provider: 'local', route: 'local.ocr', ok: true, text: 'A text', costUsd: 0, ms: 4 }] });++++ await check('an export carries only A\'s answers, compare samples and UI checks', 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_vision');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['answers.json', 'compare.json', 'ui-checks.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes(aAnswer.id));++ assert.ok(all.includes(aCheck.id));++ assert.ok(all.includes(aCompare.answer.id));++ assert.ok(!all.includes(bAnswer.id));++ assert.ok(!all.includes(bCheck.id));++ assert.ok(!all.includes('B answer'));++ const answers = part.body.files.find((f) => f.name === 'answers.json').content;++ assert.ok(answers.length >= 2);++ assert.ok(answers.every((x) => x.subject === A));++ });++++ await check('a deletion erases A\'s rows and confirms with counts; B and no one else is touched', 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;++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_compare_samples WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [B])), 1);++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.ok(confirmation.body.erased.vision_answers >= 1);++ assert.ok(confirmation.body.erased.vision_ui_checks >= 1);++ assert.ok(confirmation.body.erased.vision_compare_samples >= 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');++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const body = bodyOf(exportEvent);++ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, WRONG) } })).status, 401);++ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(body, SECRET) } })).status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * The JSON API: local OCR first, an AI answer with provenance and noLooks, a side-by-side compare, the UI check,++ * service tokens per capability, public model hints and benchmarks, and the problem+json refusals.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++ const other = t.network.addUser('sam');++ const png = await t.textImage('TOTAL 5.50');++++ try {++ await check('POST /ocr answers locally with text, confidence, zero cost and no AI run', async () => {++ const r = await t.get('/api/v1/ocr', { multipart: { image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.strictEqual(b.task, 'ocr');++ assert.match(b.text, /TOTAL 5\.50/);++ assert.strictEqual(b.answer.engine, 'local');++ assert.strictEqual(b.model, 'tesseract');++ assert.strictEqual(Number(b.cost_usd), 0);++ assert.ok(b.answer.confidence > 0);++ assert.ok(Array.isArray(b.answer.words));++ assert.strictEqual(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);++ });++++ await check('POST /ask: a read question is answered by local OCR, a describe question by AI', async () => {++ const read = await t.get('/api/v1/ask', { multipart: { fields: { question: 'What does the receipt say?' }, image: png } });++ assert.strictEqual(read.status, 201, read.text);++ assert.strictEqual(read.json().answer.engine, 'local');++ assert.match(read.json().answer.text, /TOTAL 5\.50/);++ assert.strictEqual(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);++++ const described = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });++ assert.strictEqual(described.status, 201, described.text);++ const a = described.json().answer;++ assert.strictEqual(a.answer.engine, 'ai');++ assert.strictEqual(a.provider, 'mock');++ assert.strictEqual(a.model, 'mock-vision-one');++ assert.match(a.reason, /OpenVibe\.AI routed/);++ assert.match(a.text, /red bicycle/);++ assert.ok(t.aiServer.requests.some((x) => x.path === '/v1/chat/completions' && JSON.stringify(x.body).includes('image_url')));++ });++++ await check('POST /describe is the plan\'s operation name for the same ask, and a data URL works', async () => {++ const r = await t.get('/api/v1/describe', { as: user, json: { question: 'What is this?', image: `data:image/png;base64,${png.toString('base64')}` } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().answer.task, 'describe');++ assert.strictEqual(r.json().answer.answer.engine, 'ai');++ });++++ await check('an anonymous describe is refused but an anonymous read question is local', async () => {++ const no = await t.get('/api/v1/ask', { multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });++ assert.strictEqual(no.status, 401);++ assert.strictEqual(no.json().code, 'token.required');++ const ok = await t.get('/api/v1/ask', { multipart: { fields: { question: 'What does it say?' }, image: png } });++ assert.strictEqual(ok.status, 201);++ assert.strictEqual(ok.json().answer.engine, 'local');++ });++++ await check('the AI answer is passed through noLooks even when the model names looks', async () => {++ t.aiServer.setText('A shirtless man in a kitchen laughs.');++ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What happens?' }, image: png } });++ assert.strictEqual(r.status, 201);++ assert.match(r.json().answer.text, /without describing anyone's appearance/);++ assert.ok(!/shirtless/.test(r.json().answer.text));++ const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();++ assert.match(JSON.stringify(sent.body), /Never describe a person's body/);++ });++++ await check('POST /compare runs local and AI, reports requested vs answered model, cost and latency', async () => {++ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Read and describe this image.', models: ['mock:one', 'mock:two'] }, image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.strictEqual(b.compare.length, 3);++ assert.strictEqual(b.compare[0].engine, 'local');++ assert.strictEqual(b.compare[0].cost_usd, 0);++ assert.ok(b.compare.every((x) => Number.isFinite(x.latency_ms)));++ assert.strictEqual(b.compare[1].requested_model, 'mock:one');++ assert.strictEqual(b.compare[1].model, 'mock:one');++ assert.strictEqual(b.answer.engine, 'compare');++ assert.ok(b.answer.answer.samples.length === 3);++ });++++ await check('the UI check finds a planted overflow and low contrast as JSON', async () => {++ const html = '<style>body{background:#fff;color:#999}.wide{width:1600px}</style><div class="wide">wide</div><p>low contrast</p>';++ const r = await t.get('/api/v1/ui-check', { json: { html, viewport: { w: 390, h: 844 } } });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json().check;++ assert.strictEqual(c.engine, 'static-html');++ assert.strictEqual(c.viewport.w, 390);++ assert.ok(c.findings.some((f) => f.kind === 'overflow'));++ assert.ok(c.findings.some((f) => f.kind === 'contrast'));++ assert.ok(c.findings.every((f) => f.kind && f.severity && f.selector && f.detail));++ });++++ await check('a service token with vision.ui.check can call the UI check; one without it is 403', async () => {++ const good = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'] });++ const ok = await t.get('/api/v1/ui-check', { bearer: good, json: { html: '<div style="width:900px">x</div>', viewport: { w: 390, h: 844 } } });++ assert.strictEqual(ok.status, 201, ok.text);++ const bad = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ask'] });++ const no = await t.get('/api/v1/ui-check', { bearer: bad, json: { html: '<p>x</p>' } });++ assert.strictEqual(no.status, 403);++ assert.strictEqual(no.json().code, 'capability.denied');++ });++++ await check('a service token with vision.ask can ask; the answer has no subject', async () => {++ const tok = t.network.serviceToken({ sub: 'svc:today', cap: ['vision.ask'] });++ const r = await t.get('/api/v1/ask', { bearer: tok, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().answer.answer.engine, 'ai');++ const row = await t.ctx.s.db.maybe('SELECT subject, requester FROM vision_answers WHERE id = $1', [r.json().answer.id]);++ assert.strictEqual(row.subject, null);++ assert.strictEqual(row.requester, 'service:today');++ });++++ await check('one saved answer is visible to its owner, not to another person, and to a service with the capability', async () => {++ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });++ const id = r.json().answer.id;++ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { as: user })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { as: other })).status, 404);++ const tok = t.network.serviceToken({ sub: 'svc:today', cap: ['vision.ask'] });++ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { bearer: tok })).status, 200);++ });++++ await check('GET /models and /benchmarks expose hints and aggregates, never a subject', async () => {++ const m = await t.get('/api/v1/models');++ assert.strictEqual(m.status, 200);++ assert.strictEqual(m.json().ai, true);++ assert.ok(m.json().offered.some((x) => x.model === 'mock-vision-one'));++ assert.ok(m.json().local.includes('tesseract'));++ await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Describe it.', models: ['mock:one'] }, image: png } });++ const b = await t.get('/api/v1/benchmarks');++ assert.strictEqual(b.status, 200);++ assert.ok(b.json().benchmarks.some((x) => x.engine === 'local' && x.model === 'tesseract'));++ assert.ok(!b.text.includes(user.subject));++ });++++ await check('an image URL is fetched through the public-address guard; a bad image is refused', async () => {++ const r = await t.get('/api/v1/ocr', { json: { image_url: `${t.images.url}/image.png` } });++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json().text, /HELLO VISION/);++ const bad = await t.get('/api/v1/ocr', { json: { image: 'data:image/png;base64,AAAA' } });++ assert.strictEqual(bad.status, 400);++ assert.match(bad.json().code, /vision\.image/);++ });++++ await check('problem+json is used for a malformed body, an invalid task and an unknown route', async () => {++ const bad = await t.get('/api/v1/ask', { as: user, 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 task = await t.get('/api/v1/ask', { as: user, json: { task: 'see', image: `data:image/png;base64,${png.toString('base64')}` } });++ assert.strictEqual(task.status, 400);++ assert.strictEqual(task.json().code, 'vision.invalid_task');++ const nope = await t.get('/api/v1/nope');++ assert.strictEqual(nope.status, 404);++ assert.strictEqual(nope.json().code, 'route.not_found');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,63 @@++'use strict';++/**++ * Compare: the local reader is free, each AI hint is a separate run, the model/provider reported are AI's++ * provenance (not the requested hint when AI answers differently), and /benchmarks aggregates without subjects.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('ana');++ const png = await t.textImage('TOTAL 5.50');++ try {++ await check('a compare with two hints returns three engines in order with measured cost and latency', async () => {++ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Read and describe this.', models: ['mock:one', 'mock:two'] }, image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.deepStrictEqual(b.compare.map((x) => x.engine), ['local', 'ai', 'ai']);++ assert.strictEqual(b.compare[0].cost_usd, 0);++ assert.strictEqual(b.compare[1].requested_model, 'mock:one');++ assert.strictEqual(b.compare[2].requested_model, 'mock:two');++ assert.ok(b.compare[1].latency_ms >= 0);++ assert.ok(b.compare[1].cost_usd > 0);++ assert.strictEqual(b.answer.engine, 'compare');++ assert.strictEqual(b.answer.answer.samples.length, 3);++ });++++ await check('without hints, the offered route models are read and used as hints', async () => {++ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Describe it.' }, image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.ok(b.compare.length >= 2);++ assert.ok(b.compare.some((x) => x.requested_model === 'mock:mock-vision-one'));++ assert.ok(b.compare.some((x) => x.requested_model === 'mock:mock-vision-two'));++ assert.ok(t.aiServer.requests.some((x) => x.path === '/api/v1/routes'));++ });++++ await check('a failing AI run is recorded as a failed sample; the local answer still returns', async () => {++ t.aiServer.setFail(true);++ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { models: ['mock:one'] }, image: png } });++ t.aiServer.setFail(false);++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.strictEqual(b.compare[0].ok, true);++ assert.strictEqual(b.compare[1].ok, false);++ assert.ok(b.compare[1].error_code);++ assert.match(b.answer.text, /TOTAL/);++ });++++ await check('benchmarks aggregate by engine/model and never expose a subject or answer text', async () => {++ const b = await t.get('/api/v1/benchmarks?task=ask');++ assert.strictEqual(b.status, 200);++ assert.ok(b.json().benchmarks.length >= 2);++ assert.ok(b.json().benchmarks.every((x) => x.samples >= 1 && x.avg_latency_ms >= 0 && x.avg_cost_usd >= 0));++ assert.ok(!b.text.includes(user.subject));++ assert.ok(!b.text.includes('TOTAL 5.50'));++ assert.strictEqual((await t.get('/api/v1/benchmarks?task=nope')).status, 400);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.VISION_RATE_LIMIT_PER_MIN = process.env.VISION_RATE_LIMIT_PER_MIN || '100000';++process.env.VISION_API_RATE_LIMIT_PER_MIN = process.env.VISION_API_RATE_LIMIT_PER_MIN || '100000';++process.env.VISION_LIMITS_MINUTE = process.env.VISION_LIMITS_MINUTE || '100000';++process.env.VISION_LIMITS_HOUR = process.env.VISION_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Vision on a temp database with a mock Network (test/helpers/mocks.js), a mock OpenVibe.AI and a++ * local image fixture, 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, multipart, headers, method, cookie, origin })++ * t.signIn(user) → cookie header value (vision_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork, startAi } = require('./mocks');++const { startImageServer, startPageServer, textImage } = require('./images');++++const captured = [];++for (const m of ['log', 'info', 'warn', 'error']) {++ const orig = console[m].bind(console);++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };++}++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const aiServer = await startAi(opts.ai || {});++ const images = await startImageServer(opts.images || {});++ const pages = await startPageServer(opts.pages || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.vision', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'vision', OV_OAUTH_CLIENT_SECRET: 'vision-secret', COOKIE_SECURE: 'false',++ VISION_DRILL: '1', VISION_ALLOW_PRIVATE_URLS: '1',++ VISION_EVENTS_URL: network.url,++ OV_AI_URL: aiServer.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, ocr: opts.ocr, ai: opts.aiClient });++ 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) => `vision_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'; }++ if (o.multipart) {++ const fd = new FormData();++ for (const [k, v] of Object.entries(o.multipart.fields || {})) {++ if (Array.isArray(v)) for (const x of v) fd.append(k, String(x));++ else if (v != null) fd.append(k, String(v));++ }++ if (o.multipart.image) fd.append('image', new Blob([o.multipart.image], { type: o.multipart.imageType || 'image/png' }), o.multipart.filename || 'image.png');++ body = fd;++ }++ 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, aiServer, images, pages, config, ctx: built.ctx, get, signIn, dbDump, textImage,++ 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();++ await aiServer.close();++ await images.close();++ await pages.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, 10).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * VISION_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.VISION_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'vision', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++/** A tiny public-image fixture and a rendered text image for the local OCR and UI checks. */++const http = require('http');++const sharp = require('sharp');++++async function textImage(text = 'HELLO VISION', { width = 640, height = 220, background = '#ffffff', color = '#000000' } = {}) {++ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="${background}"/><text x="24" y="${Math.round(height * 0.62)}" font-family="DejaVu Sans" font-size="${Math.round(height * 0.38)}" fill="${color}">${String(text).replace(/[<>&]/g, '')}</text></svg>`;++ return await sharp(Buffer.from(svg)).png().toBuffer();++}++++async function startImageServer({ png = null } = {}) {++ const body = png || await textImage('HELLO VISION');++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ if (req.url === '/image.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': body.length }); return res.end(body); }++ if (req.url === '/redirect') { res.writeHead(302, { Location: '/image.png' }); return res.end(); }++ res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found');++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return {++ url: `http://127.0.0.1:${server.address().port}`,++ png: body,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ };++}++++async function startPageServer({ html = '<!doctype html><html><body><p>ok</p></body></html>' } = {}) {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ if (req.url === '/page') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html); }++ if (req.url === '/redirect') { res.writeHead(302, { Location: '/page' }); return res.end(); }++ res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found');++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return {++ url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ };++}++++module.exports = { textImage, startImageServer, startPageServer };nnew file mode 100644@@@ -0,0 +1,192 @@++'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 `vision`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Vision, 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 !== 'vision' || body.client_secret !== 'vision-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:vision', 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,++ serviceToken({ sub = 'svc:design', cap = ['vision.ui.check'], audience = 'openvibe.vision', ttl = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + ttl, jti: crypto.randomUUID() }, privatePem);++ },++ 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 };++++++/**++ * An in-process OpenVibe.AI stand-in: the OpenAI-compatible endpoint and the route list. It does not verify the++ * token cryptographically; it records the Authorization header's shape and the request body, and answers with a++ * provider/model/cost so the tests exercise Vision's provenance path.++ */++async function startAi({ defaultModel = 'mock-vision-one', provider = 'mock', cost = 0.0004, fail = false, text = null } = {}) {++ const state = { requests: [], fail: false, text };++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ const body = raw.length ? JSON.parse(raw.toString('utf8') || '{}') : {};++ state.requests.push({ method: req.method, path: url.pathname, auth: req.headers.authorization || '', body });++ if (!String(req.headers.authorization || '').startsWith('Bearer ')) return json(401, { error: { message: 'missing token', type: 'authentication_error', code: 'token.required' } });++ if (state.fail) return json(503, { error: { message: 'AI is down for this test', type: 'api_error', code: 'ai.unavailable' } });++ if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {++ const asked = String(body.model || defaultModel);++ const hasImage = JSON.stringify(body.messages || []).includes('image_url');++ const prompt = JSON.stringify(body.messages || []);++ let out = state.text;++ if (out == null) {++ if (!hasImage) out = 'No image was attached.';++ else if (/Read every visible piece of text|Read all visible text/.test(prompt)) out = 'HELLO VISION\nTOTAL 5.50';++ else if (/How many|count/i.test(prompt)) out = '3 objects.';++ else out = 'A red bicycle leans against a wall beside a window.';++ }++ return json(200, {++ id: `chatcmpl-${crypto.randomBytes(6).toString('hex')}`,++ object: 'chat.completion', model: asked,++ choices: [{ index: 0, message: { role: 'assistant', content: out }, finish_reason: 'stop' }],++ usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144 },++ x_openvibe: { run_id: `run_${crypto.randomBytes(8).toString('hex')}`, provider, fallback_used: false, cost_usd: cost, synthetic: false },++ });++ }++ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {++ return json(200, { routes: [++ { key: 'default.chat', capability: 'chat', primary: { provider, model: 'mock-vision-one' }, fallbacks: [{ provider, model: 'mock-vision-two' }] },++ ] });++ }++ if (url.pathname === '/api/v1/runs/fail' && req.method === 'GET') return json(200, { run: { status: 'failed' } });++ return json(404, { error: { message: 'not found', code: 'route.not_found' } });++ });++ return {++ url: srv.url, requests: state.requests,++ setFail(v) { state.fail = Boolean(v); },++ setText(v) { state.text = v == null ? null : String(v); },++ close: srv.close,++ };++}++++module.exports = { startNetwork, startAi, signJwt };nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * The expand migration applies in one pass and creates exactly the Vision tables, plus the SDK's account receipts++ * and telemetry. The answer constraints refuse a bad id, a task outside v1 and an unknown engine.++ */++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 Vision 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 ['vision_answers', 'vision_compare_samples', 'vision_ui_checks', 'account_data_events', 'telemetry_samples']) {++ assert.ok(names.includes(table), `missing ${table}`);++ }++ assert.ok(!names.includes('vision_outbox'), 'v1 has no public records and no Search outbox');++ });++++ await check('an answer cannot claim a bad id, an unknown task or a bad image hash', async () => {++ const db = t.ctx.s.db;++ const sha = 'a'.repeat(64);++ await assert.rejects(db.exec(++ "INSERT INTO vision_answers (id, requester, task, source, image_sha256, image_mime, image_bytes) VALUES ('bad', 'user:usr_x', 'see', 'upload', $1, 'image/png', 10)",++ [sha],++ ), /vision_answers_id|vision_answers_task/);++ await assert.rejects(db.exec(++ "INSERT INTO vision_answers (id, requester, task, source, image_sha256, image_mime, image_bytes) VALUES ($1, 'user:usr_x', 'ask', 'upload', 'nope', 'image/png', 10)",++ [`vis_${ids.ulid()}`],++ ), /vision_answers_sha/);++ });++++ await check('a compare sample only names local or ai and a v1 task', async () => {++ const db = t.ctx.s.db;++ const id = `vcm_${ids.ulid()}`;++ await assert.rejects(db.exec("INSERT INTO vision_compare_samples (id, task, engine) VALUES ($1, 'ask', 'cloud')", [id]), /vision_compare_samples_engine/);++ await assert.rejects(db.exec("INSERT INTO vision_compare_samples (id, task, engine) VALUES ($1, 'see', 'ai')", [id]), /vision_compare_samples_task/);++ });++++ await check('a UI check keeps findings as jsonb and a valid viewport', async () => {++ const db = t.ctx.s.db;++ const id = `vuc_${ids.ulid()}`;++ await db.exec("INSERT INTO vision_ui_checks (id, requester, source, engine, findings, summary) VALUES ($1, 'anonymous', 'html', 'static-html', '[{\"kind\":\"overflow\"}]'::jsonb, '{}'::jsonb)", [id]);++ const row = await db.maybe('SELECT findings FROM vision_ui_checks WHERE id = $1', [id]);++ assert.strictEqual(row.findings[0].kind, 'overflow');++ await assert.rejects(db.exec("INSERT INTO vision_ui_checks (id, requester, source, engine, viewport_w) VALUES ($1, 'anonymous', 'html', 'static-html', 10)", [`vuc_${ids.ulid()}`]), /vision_ui_checks_viewport/);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++/**++ * No looks in public AI text: a summary that names looks becomes the title; an answer that names looks is replaced,++ * never repaired word by word; a normal answer survives; and the prompt carries the rule.++ */++const assert = require('assert');++const { cleanAnswer, cleanSummary, hasLooks, SAFE_ANSWER } = require('../server/text/no-looks');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ await check('an answer that names looks is replaced by the neutral sentence', async () => {++ assert.strictEqual(cleanAnswer('A shirtless man in a kitchen laughs.'), SAFE_ANSWER);++ assert.ok(hasLooks('A woman with long blonde hair smiles.'));++ assert.strictEqual(cleanAnswer('A red bicycle leans on a wall.'), 'A red bicycle leans on a wall.');++ assert.strictEqual(cleanSummary('Robot is offline', 'A shirtless man reacts.'), 'Robot is offline');++ });++++ const t = await boot();++ try {++ await check('the AI prompt forbids looks and the stored answer is cleaned', async () => {++ t.aiServer.setText('A tall woman in a red dress holds a sign.');++ const user = t.network.addUser('kim');++ const png = await t.textImage('HELLO');++ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is happening?' }, image: png } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json().answer.text, SAFE_ANSWER);++ const stored = await t.ctx.s.db.value('SELECT text FROM vision_answers WHERE id = $1', [r.json().answer.id]);++ assert.strictEqual(stored, SAFE_ANSWER);++ const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();++ assert.match(JSON.stringify(sent.body), /Never describe a person's body/);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,67 @@++'use strict';++/**++ * The local reader: a rendered text image is read with confidence and boxes, a read question stops there, and the++ * AI fallback runs only when the local reader finds nothing. A missing engine is an honest 503.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { parseTsv } = require('../server/vision/ocr');++++(async () => {++ await check('parseTsv keeps reading order, words and mean confidence', async () => {++ const tsv = [++ 'level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext',++ '5\t1\t1\t1\t1\t1\t10\t10\t40\t20\t90\tHELLO',++ '5\t1\t1\t1\t1\t2\t60\t10\t70\t20\t80\tVISION',++ '5\t1\t1\t1\t2\t1\t10\t40\t50\t20\t70\tTOTAL',++ '5\t1\t1\t1\t2\t2\t70\t40\t30\t20\t60\t5.50',++ ].join('\n');++ const out = parseTsv(tsv);++ assert.strictEqual(out.text, 'HELLO VISION\nTOTAL 5.50');++ assert.strictEqual(out.words.length, 4);++ assert.strictEqual(out.words[0].text, 'HELLO');++ assert.ok(out.confidence > 70 && out.confidence < 80);++ });++++ const t = await boot();++ try {++ await check('the local reader reads a rendered image with no AI call and zero cost', async () => {++ const png = await t.textImage('HELLO VISION');++ const r = await t.get('/api/v1/ocr', { multipart: { image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.match(b.text, /HELLO VISION/);++ assert.strictEqual(b.answer.engine, 'local');++ assert.strictEqual(b.model, 'tesseract');++ assert.strictEqual(Number(b.cost_usd), 0);++ assert.ok(b.answer.confidence >= 0);++ assert.ok(b.answer.words.some((w) => w.text === 'HELLO'));++ });++++ await check('a blank image with fallback=1 asks AI only after the local pass found nothing', async () => {++ const blank = await t.textImage('');++ const before = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length;++ const r = await t.get('/api/v1/ocr', { as: t.network.addUser('reader'), multipart: { fields: { fallback: '1' }, image: blank } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ assert.strictEqual(b.answer.engine, 'ai');++ assert.match(b.text, /HELLO VISION/);++ assert.ok(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length > before);++ });++ } finally {++ await t.close();++ }++++ const missing = await boot({ ocr: { recognize: async () => ({ ok: false, code: 'vision.ocr_unavailable', detail: 'the local OCR engine (tesseract) is not installed on this host' }), available: async () => null } });++ try {++ await check('a missing local engine answers 503, not a silent cloud call', async () => {++ const r = await missing.get('/api/v1/ocr', { json: { image: `data:image/png;base64,${(await missing.textImage('X')).toString('base64')}` } });++ assert.strictEqual(r.status, 503);++ assert.strictEqual(r.json().code, 'vision.ocr_unavailable');++ assert.strictEqual(missing.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);++ });++ } finally {++ await missing.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,129 @@++'use strict';++/**++ * Server-rendered pages: the home, ask, OCR, compare, the UI check, one saved answer and one saved UI check, the++ * developers page, 404, cache headers, and the content-hashed assets. Public pages are public, max-age=60; a page++ * drawn for a signed-in person is private, no-store.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { assetVersion } = require('../server/render/layout');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++function jsonLd(html) {++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++}++const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</style><div class="wide">wide</div><p>low contrast</p>';++++(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' });++ const png = await t.textImage('HELLO VISION');++ try {++ await check('home: semantic, the room, the try section, public cache with Vary: Cookie', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ for (const marker of ['id="vi-home"', 'id="vi-try"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', '/ask', '/ocr', '/compare', '/ui-check']) assert.ok(r.text.includes(marker), marker);++ const types = jsonLd(r.text).map((x) => x['@type']);++ assert.ok(types.includes('WebSite'), types.join(','));++ assert.ok(types.includes('WebApplication'), types.join(','));++ });++++ await check('a page drawn for a signed-in person is private, no-store', async () => {++ const r = await t.get('/', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ });++++ await check('POST /ask saves an answer and redirects to its private page', async () => {++ const r = await t.get('/ask', { as: alice, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });++ assert.strictEqual(r.status, 303, r.text);++ assert.match(r.headers.get('location'), /^\/a\/vis_/);++ const id = r.headers.get('location').split('/').pop();++ const page = await t.get(`/a/${id}`, { as: alice });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="vi-answer"'));++ assert.ok(page.text.includes('red bicycle'));++ assert.strictEqual((await t.get(`/a/${id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/a/${id}`, { as: mod })).status, 200);++ assert.strictEqual((await t.get(`/a/${id}`)).status, 404);++ });++++ await check('POST /ocr works without an account and renders the reading in the page', async () => {++ const r = await t.get('/ocr', { multipart: { image: png } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('id="vi-ocr-result"'));++ assert.ok(r.text.includes('HELLO VISION'));++ const saved = await t.get('/ocr', { as: alice, multipart: { image: png } });++ assert.strictEqual(saved.status, 303);++ assert.match(saved.headers.get('location'), /^\/a\/vis_/);++ });++++ await check('/compare lists hints and a signed-in compare redirects to a saved answer', async () => {++ const form = await t.get('/compare', { as: alice });++ assert.strictEqual(form.status, 200);++ assert.ok(form.text.includes('id="vi-compare-form"'));++ assert.ok(form.text.includes('mock:mock-vision-one'));++ const r = await t.get('/compare', { as: alice, multipart: { fields: { question: 'Read it.', models: ['mock:one'] }, image: png } });++ assert.strictEqual(r.status, 303, r.text);++ assert.match(r.headers.get('location'), /^\/a\/vis_/);++ });++++ await check('POST /ui-check works anonymously and saves for a signed-in person', async () => {++ const anon = await t.get('/ui-check', { multipart: { fields: { html: PLANTED, viewport_w: '390', viewport_h: '844' } } });++ assert.strictEqual(anon.status, 200, anon.text);++ assert.ok(anon.text.includes('id="vi-ui-result"'));++ assert.ok(anon.text.includes('vi-finding-overflow'));++ assert.ok(anon.text.includes('vi-finding-contrast'));++ const saved = await t.get('/ui-check', { as: alice, multipart: { fields: { html: PLANTED, viewport_w: '390', viewport_h: '844' } } });++ assert.strictEqual(saved.status, 303, saved.text);++ assert.match(saved.headers.get('location'), /^\/ui\/vuc_/);++ const id = saved.headers.get('location').split('/').pop();++ const page = await t.get(`/ui/${id}`, { as: alice });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('id="vi-check"'));++ assert.ok(page.text.includes('id="vi-findings"'));++ assert.strictEqual((await t.get(`/ui/${id}`, { as: bob })).status, 404);++ });++++ await check('/developers names the API and the capabilities; the 404 is the Vision page', async () => {++ const dev = await t.get('/developers');++ assert.strictEqual(dev.status, 200);++ assert.strictEqual(dev.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(dev.text.includes('id="vi-developers"'));++ assert.ok(dev.text.includes('vision.ui.check'));++ assert.ok(dev.text.includes('/api/v1/ui-check'));++ 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="vi-not-found"'));++ });++++ await check('assets are content-hashed; robots and sitemap list the public pages', async () => {++ const v = assetVersion('css/vision.css');++ const hashed = await t.get(`/css/vision.css?v=${v}`);++ assert.strictEqual(hashed.status, 200);++ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ const wrong = await t.get('/css/vision.css?v=deadbeefdeadbeef');++ assert.strictEqual(wrong.status, 200);++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');++ const robots = await t.get('/robots.txt');++ assert.match(robots.text, /Disallow: \/api\//);++ assert.match(robots.text, /Disallow: \/a\//);++ const map = await t.get('/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.ok(map.text.includes('https://openvibe.vision/ui-check'));++ const llms = await t.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.ok(llms.text.includes('OpenVibe.Vision'));++ assert.match(llms.text, /No face recognition/);++ });++ } 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, an++ * in-process mock of OpenVibe.Network, a mock OpenVibe.AI and local image/page fixtures; none needs the network.++ *++ * npm test # everything++ * npm test -- ocr ui # 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,56 @@++'use strict';++/**++ * The public-URL guard, token audiences and signatures, and the promise that no secret is rendered or logged.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { assertPublicUrl, isPrivateAddress, loadImage } = require('../server/vision/images');++++(async () => {++ await check('private, loopback, link-local and unique-local addresses are refused', async () => {++ for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '::1', 'fd00::1', 'fe80::1', '0.0.0.0']) assert.strictEqual(isPrivateAddress(a), true, a);++ for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.strictEqual(isPrivateAddress(a), false, a);++ await assert.rejects(assertPublicUrl('http://127.0.0.1/x', { allowPrivate: false }), (e) => e.code === 'image.private_url');++ await assert.rejects(assertPublicUrl('file:///etc/passwd'), (e) => e.code === 'image.invalid_url');++ await assert.rejects(assertPublicUrl('https://user:[email protected]/x'), (e) => e.code === 'image.invalid_url');++ });++++ const t = await boot();++ const user = t.network.addUser('kim');++ try {++ await check('loadImage refuses a private URL unless the host explicitly allows it', async () => {++ await assert.rejects(loadImage({ imageUrl: `${t.images.url}/image.png`, config: { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: false } }, fetchImpl: t.fetchImpl }), (e) => e.code === 'image.private_url');++ const ok = await loadImage({ imageUrl: `${t.images.url}/image.png`, config: { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: true } } });++ assert.strictEqual(ok.source, 'url');++ assert.strictEqual(ok.mime, 'image/png');++ assert.match(ok.sha256, /^[0-9a-f]{64}$/);++ });++++ await check('a service token for another audience and a forged token are 401', async () => {++ const wrongAud = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'], audience: 'openvibe.design' });++ const r = await t.get('/api/v1/ui-check', { bearer: wrongAud, json: { html: '<p>x</p>' } });++ assert.strictEqual(r.status, 401);++ const forged = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'] }).slice(0, -2) + 'aa';++ assert.strictEqual((await t.get('/api/v1/ui-check', { bearer: forged, json: { html: '<p>x</p>' } })).status, 401);++ });++++ await check('the session cookie never appears in a page or /auth/me, and the logs carry no token', async () => {++ const token = t.network.userToken(user);++ const page = await t.get('/', { cookie: `vision_at=${token}` });++ assert.strictEqual(page.status, 200);++ assert.ok(!page.text.includes(token));++ const me = await t.get('/auth/me', { cookie: `vision_at=${token}` });++ assert.ok(!me.text.includes(token));++ assert.ok(!t.logs().includes(token));++ assert.ok(!t.logs().includes('vision-secret'));++ });++++ await check('an image URL that redirects to a private address is still refused when the guard is on', async () => {++ const local = { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: false } };++ await assert.rejects(loadImage({ imageUrl: `${t.images.url}/redirect`, config: local }), (e) => e.code === 'image.private_url');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id vision, cookies vision_at/vision_rt/vision_oauth), 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 vision', async () => {++ const r = await t.get('/auth/login?next=/ask');++ 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'), 'vision');++ 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'), /vision_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=/ask');++ 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'), '/ask');++ const set = r.headers.get('set-cookie');++ assert.match(set, /vision_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /vision_rt=[^;]+;.*HttpOnly/i);++ assert.match(set, /vision_oauth=;/);++ const at = set.match(/vision_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `vision_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(/vision_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://openvibe.vision' };++ assert.strictEqual(sanitizeNext('/ask?x=1', config), '/ask?x=1');++ assert.strictEqual(sanitizeNext('https://openvibe.vision/ui-check', config), '/ui-check');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.vision.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ });++++ await check('readiness is truthful: db required; JWKS, OAuth, AI, Valkey and local OCR 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.ok('ai' in b.checks);++ assert.ok('local_ocr' in b.checks);++ 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, 'vision');++ 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 ['/', '/ask', '/ocr', '/compare', '/ui-check', '/developers']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript><nav/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {++ const ping = await t.get('/api/v1/ping');++ assert.strictEqual(ping.status, 200);++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'vision' });++ 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: `vision_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `vision_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: `vision_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: 'vision_at=garbage' })).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.match(cross.text, /method="post"/);++ assert.strictEqual((await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);++ const same = await t.get('/auth/logout?next=/ask', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/ask');++ assert.match(String(same.headers.get('set-cookie') || ''), /vision_at=;/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++/**++ * The UI check: a planted overflow, cut-off line, low contrast, overlapping boxes and a layering fault are found in++ * static HTML; the same page is fetched from a URL; a screenshot gets the OCR-box and metadata pass; and the colour++ * maths is the WCAG ratio.++ */++const assert = require('assert');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++const { checkHtml, contrastRatio, parseColor } = require('../server/vision/ui-check');++++const PLANTED = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>++body{background:#ffffff;color:#9a9a9a;font-size:16px}++.wide{width:1400px;height:40px;background:#eeeeee}++.cut{width:120px;overflow:hidden;white-space:nowrap}++.a,.b{position:absolute;top:20px;width:200px;height:100px;background:#dddddd}++.a{left:20px}.b{left:100px;top:60px;z-index:3}++.ghost{z-index:9}++.fixed{position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.2)}++</style></head><body><p class="ghost">Low contrast words here</p><div class="wide">wide</div><div class="cut">This is a long line that will be cut off</div><div class="a">A</div><div class="b">B</div><div class="fixed">overlay</div></body></html>`;++++(async () => {++ await check('WCAG contrast maths', async () => {++ assert.ok(Math.abs(contrastRatio(parseColor('#000'), parseColor('#fff')) - 21) < 0.1);++ assert.ok(Math.abs(contrastRatio(parseColor('#777'), parseColor('#fff')) - 4.48) < 0.1);++ assert.ok(contrastRatio(parseColor('#999'), parseColor('#fff')) < 3);++ });++++ await check('a planted overflow, cut-off, low contrast, overlap and layering fault are all found', async () => {++ const out = checkHtml(PLANTED, { viewport: { w: 1280, h: 800 } });++ assert.strictEqual(out.engine, 'static-html');++ for (const kind of ['overflow', 'cut_off', 'contrast', 'overlap', 'layering']) {++ assert.ok(out.findings.some((f) => f.kind === kind), `missing ${kind}: ${JSON.stringify(out.counts)}`);++ }++ assert.ok(out.findings.every((f) => f.severity && f.selector && f.detail));++ assert.match(out.summary, /UI faults? found/);++ assert.ok(out.warnings.some((w) => /JavaScript was not executed/.test(w)));++ });++++ await check('a narrow viewport turns a 1400px card into an overflow; 1280px still reports it', async () => {++ const narrow = checkHtml(PLANTED, { viewport: { w: 390, h: 844 } });++ assert.ok(narrow.findings.some((f) => f.kind === 'overflow' && f.evidence.viewport === 390));++ const out = checkHtml('<div style="width:100vw;padding-left:20px;box-sizing:content-box">x</div>', { viewport: { w: 390, h: 800 } });++ assert.ok(out.findings.some((f) => f.kind === 'overflow'));++ const percent = checkHtml('<style>body{margin:0}.x{width:100%;padding-left:20px;box-sizing:content-box}</style><div class="x">x</div>', { viewport: { w: 390, h: 800 } });++ assert.ok(percent.findings.some((f) => f.kind === 'overflow'));++ });++++ const t = await boot();++ try {++ await check('POST /ui-check returns saved findings with a stable vuc_ id', async () => {++ const r = await t.get('/api/v1/ui-check', { json: { html: PLANTED, viewport: { w: 390, h: 844 } } });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json().check;++ assert.match(c.id, /^vuc_/);++ assert.strictEqual(c.source, 'html');++ assert.strictEqual(c.engine, 'static-html');++ assert.strictEqual(c.viewport.w, 390);++ assert.ok(c.findings.length >= 4);++ const saved = await t.ctx.store.uiCheckById(c.id);++ assert.strictEqual(saved.findings.length, c.findings.length);++ });++++ await check('a URL is fetched and checked locally', async () => {++ const r = await t.get('/api/v1/ui-check', { json: { url: `${t.pages.url}/page`, viewport: { w: 390, h: 844 } } });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json().check;++ assert.strictEqual(c.source, 'url');++ assert.match(c.url, /\/page$/);++ assert.ok(c.summary);++ });++++ await check('a screenshot gets the local OCR-box and pixel pass, and says what it cannot see', async () => {++ const png = await sharp({ create: { width: 640, height: 220, channels: 4, background: '#ffffff' } }).png().toBuffer();++ const r = await t.get('/api/v1/ui-check', { multipart: { image: png } });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json().check;++ assert.strictEqual(c.engine, 'screenshot');++ assert.strictEqual(c.source, 'screenshot');++ assert.strictEqual(c.viewport.w, 640);++ assert.ok(c.summary.warnings.some((w) => /cannot show CSS layering/.test(w)));++ assert.ok(Array.isArray(c.findings));++ });++++ await check('the API refuses an empty UI check and an oversized HTML body', async () => {++ const empty = await t.get('/api/v1/ui-check', { json: {} });++ assert.strictEqual(empty.status, 400);++ assert.strictEqual(empty.json().code, 'vision.invalid');++ const big = 'x'.repeat((t.config.ui.maxHtmlBytes || 1024 * 1024) + 10);++ const tooBig = await t.get('/api/v1/ui-check', { json: { html: big } });++ assert.ok([413, 400].includes(tooBig.status), String(tooBig.status));++ });++ } finally {++ await t.close();++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+# OpenVibe.Vision — environment (production: /etc/openvibe/vision.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5130
+HOST=127.0.0.1
+BASE_URL=https://openvibe.vision
+TRUST_PROXY=2
+# PostgreSQL (ADR-035): the runtime role through PgBouncer and the owner on a direct connection for migrations.
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:vision:
+# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
+#VISION_LIMITS_MINUTE=60
+#VISION_LIMITS_HOUR=600
+#VISION_API_RATE_LIMIT_PER_MIN=120
+#VISION_RATE_LIMIT_PER_MIN=180
+
+# OpenVibe.Network: SSO (OAuth client `vision`, PKCE S256, cookies vision_at/vision_rt/vision_oauth) and its JWKS;
+# service/app tokens for audience openvibe.vision.
+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=vision
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.vision/auth/callback
+# VISION_AUDIENCE=openvibe.vision
+# OV_SESSION_AUDIENCE=openvibe.network
+# VISION_PGLITE_DIR=./data/pglite
+
+# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
+# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
+VISION_EVENTS_SECRET=
+VISION_EVENTS_URL=
+#VISION_EVENTS_ENDPOINT=http://127.0.0.1:5130/internal/events
+#VISION_EVENTS_INTERVAL_MS=5000
+
+# OpenVibe.AI: the only model gateway. A first-party service token for client `vision` (Network grant
+# ai.run.create, namespace ai.*) makes the run; AI's routing, quotas, cache and cost accounting apply. Vision
+# holds no provider key and calls no paid provider directly. Unset the client secret and only local engines serve.
+OV_AI_URL=https://ai.openvibe.services
+# VISION_AI_AUDIENCE=openvibe.ai
+# VISION_AI_SCOPE=ai.run.create
+# VISION_AI_NAMESPACE=ai.*
+# VISION_AI_MODELS=openai:gpt-5-mini,local:qwen
+#VISION_AI_TIMEOUT_MS=45000
+#VISION_AI_WAIT_MS=30000
+#VISION_AI_MAX_TOKENS=700
+#VISION_AI_TEMPERATURE=0.1
+
+# The local OCR engine (Tesseract 5). A missing binary is an honest 503; no cloud fallback without consent.
+VISION_TESSERACT=tesseract
+#VISION_OCR_LANG=eng
+#VISION_OCR_TIMEOUT_MS=20000
+
+# Image inputs: upload, data URL or public URL. URLs are fetched only after every resolved address is public.
+#VISION_IMAGE_MAX_MB=8
+#VISION_IMAGE_TIMEOUT_MS=10000
+#VISION_ALLOW_PRIVATE_URLS=0
+
+# The UI check: HTML, URL or screenshot. It never executes JavaScript and never fetches remote stylesheets.
+#VISION_UI_MAX_HTML_KB=1024
+#VISION_UI_TIMEOUT_MS=10000
+#VISION_UI_MAX_FINDINGS=200
+
+# A restore drill starts no account subscriptions.
+#VISION_DRILL=0
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+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.
+ # The local OCR tests need Tesseract 5; install it on a runner that lacks it.
+ test: 'command -v tesseract >/dev/null || (sudo apt-get update && sudo apt-get install -y tesseract-ocr); 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,37 @@
@@ -0,0 +1,37 @@
+# Changelog
+
+What changed in OpenVibe.Vision, newest first.
+
+## 0.1.0 — 2026-10-28
+
+The first version: see with the best model for the job.
+
+- **Ask a picture.** `POST /api/v1/ask` takes a multipart image, an image data URL or a public `image_url`, plus a
+ question and one of `ask|describe|identify|count|translate|ocr`. A read question is answered by the local OCR
+ reader first; anything else goes through OpenVibe.AI. The answer names the engine, model, provider, route, reason,
+ cost and measured latency.
+- **Local OCR first.** `POST /api/v1/ocr` runs Tesseract 5 on this host (one OpenMP thread, a temporary file that is
+ removed), returns the text, mean confidence and word boxes, and costs zero. `fallback=1` asks AI only when the
+ local pass found no usable text; a missing binary is an honest 503, never a silent cloud call.
+- **Compare side by side.** `POST /api/v1/compare` runs the local reader and up to three AI hints on the same image.
+ The requested model is only a hint: the provider and model reported are AI's own provenance. Each row carries
+ success, text, cost and latency; `GET /api/v1/benchmarks` aggregates them per engine/model without subjects.
+- **The UI check.** `POST /api/v1/ui-check` reads HTML, a public page URL or a screenshot locally. The static pass
+ estimates boxes and inline CSS to find overflow, cut-off text, WCAG contrast failures, overlapping positioned
+ boxes and layering faults (`z-index` without position, a full-viewport fixed box). The screenshot pass uses the
+ local OCR boxes and sharp pixels. Findings are data: kind, severity, selector, detail, evidence. Design's Arena
+ calls it with `vision.ui_check`.
+- **The AI gateway.** Vision calls OpenVibe.AI's OpenAI-compatible endpoint with a first-party service token
+ (`ai.run.create`, namespace `ai.*`). AI's routing, quotas, cache, provenance and cost accounting apply. Vision holds
+ no provider key, adds no provider and calls no paid provider directly.
+- **No looks, no faces.** Public AI text goes through `noLooks`; an answer that names a person's looks is replaced by
+ a neutral sentence. Face recognition is not implemented and is not called, ever.
+- **Privacy.** No image bytes are stored: an answer keeps a sha256, the media type, the cleaned text and the model
+ provenance. Answers, compare samples and UI checks are exported and erased with the person's account.
+- **The service.** Network sign-in as OAuth client `vision` (PKCE S256, `vision_at`/`vision_rt`/`vision_oauth`),
+ truthful readiness, Prometheus metrics on loopback, discovery, a systemd unit, an nginx vhost and CI. Pages are
+ server-rendered with stable `vi-` ids/classes; `public/css/vision.css` and `public/js/vision.js` are
+ one-comment placeholders for the main session.
+- **Tests.** Migration, SSO, API, OCR, compare/benchmarks, the UI check (including a planted overflow), noLooks,
+ pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock
+ Network, a mock OpenVibe.AI and local image/page fixtures.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+# OpenVibe.Vision
+
+> See with the best model for the job.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `openvibe.vision` · **Port:** 5130 · **Service id:** `vision` · **Env prefix:** `VISION`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Vision answers questions about a picture. The cheapest engine goes first: the local Tesseract reader
+answers a read question for nothing; otherwise OpenVibe.AI's routed vision model answers. The same image can be run
+through the local reader and up to three AI model hints side by side, with the model that actually answered, its
+cost and its measured latency. The UI check reads HTML, a public URL or a screenshot on this host and returns
+overflow, cut-off text, low contrast, overlap and layering faults as JSON. **No face recognition, ever.**
+
+## v1 scope (plan §5.8)
+
+- **Ask a picture.** Describe, read, identify, count, translate. Upload a file, send a data URL, or paste a public
+ image URL. Every answer reports the engine, model, provider, route, why it was chosen, the cost and the latency.
+- **OCR with a local engine first.** Tesseract 5 runs on this host; no image leaves it and no model is called. An
+ AI fallback is opt-in (`fallback=1`) and runs only when the local pass found no usable text.
+- **Compare models side by side.** One image, the local reader and one to three AI model hints. AI's router is the
+ authority: `requested_model` is only the hint, `model`/`provider` are what AI says answered. Each sample records
+ cost and latency; `GET /api/v1/benchmarks` aggregates them with no subject or answer text.
+- **The UI check as an API.** `POST /api/v1/ui-check` is what Design's Arena will call, with a service token holding
+ `vision.ui.check`. It also works for a signed-in person and, for HTML/URL/screenshot, anonymously.
+- **No faces, no looks.** No face recognition is implemented or called; the prompt also forbids naming or identifying a person. Public AI text is passed through `noLooks`;
+ an answer that names a person's looks is replaced by a neutral sentence.
+- **Privacy.** No image bytes are stored: a sha256, media type and byte count are. Answers, compare samples and UI
+ checks are exported and erased with the person's account.
+- **Not in v1.** Public Arena leaderboards, video, computer-use benchmarks and OpenCV jobs on Run (v2/v3). v1 has no
+ public records, so it writes no Search document and has no outbox.
+
+## What runs where
+
+| Step | Where | Cost | Leaves the host? |
+|---|---|---|---|
+| Image decode, rotate, resize, sha256 | this host, `sharp` | 0 | no |
+| OCR | this host, Tesseract 5 (`OMP_THREAD_LIMIT=1`) | 0 | no |
+| UI check (HTML/URL) | this host, static parser + WCAG maths | 0 | the URL fetch only (public HTTP) |
+| UI check (screenshot) | this host, OCR boxes + `sharp` pixels | 0 | no |
+| Ask / describe / identify / count / translate | OpenVibe.AI's routed vision model | AI's own accounting | the normalised image, to OpenVibe.AI |
+| OCR AI fallback | OpenVibe.AI's routed vision model | AI's own accounting | only after an empty local pass and with consent |
+| Compare | local + AI hints | local free; AI's own accounting | the normalised image, to OpenVibe.AI |
+
+Vision holds no provider key, adds no provider and calls no paid provider directly. `ai.run.create` is the only
+outbound capability; AI's quotas and its own route decide whether a run is allowed.
+
+## Owns
+
+| Table | Holds | Migration |
+|---|---|---|
+| `vision_answers` | `vis_` + ULID, subject (`usr_…` or null), requester, task `ask\|describe\|ocr\|compare`, question, mode, source, public `source_url`, image sha256/mime/bytes, cleaned `text`, `answer` jsonb (engine, reason, words, samples, run id), model/provider/route, cost, latency | `migrations/0001_initial.sql` |
+| `vision_compare_samples` | `vcm_` + ULID, answer, subject, task, engine `local\|ai`, requested model, answered model/provider/route, ok, text, cost, latency, error code | `0001_initial.sql` |
+| `vision_ui_checks` | `vuc_` + ULID, subject, requester, source `html\|url\|screenshot`, URL, image sha256, viewport, engine `static-html\|screenshot`, findings jsonb, summary jsonb | `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` |
+
+No table stores image bytes, a token or a provider key.
+
+## Does not own
+
+- **Models, routing, quotas and provider keys** — OpenVibe.AI. Vision makes first-party `ai.run.create` runs with
+ namespace `ai.*`; AI decides the provider/model and reports the provenance.
+- **Identity, roles and accounts** — OpenVibe.Network. Vision verifies session and service tokens against its JWKS.
+- **The local OCR binary and language data** — the host (Tesseract under `/usr/bin` and `/usr/share`).
+- **Search** — OpenVibe.Search. v1 has no public records to index.
+- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
+
+## Capabilities and grants
+
+The service offers four capabilities. The main session registers their manifests and grants them; v1 checks the
+grant list directly, so a token that already carries one works.
+
+| Capability | Routes | Typical caller |
+|---|---|---|
+| `vision.ask` | `POST /api/v1/ask`, `GET /api/v1/answers/:id` | a first-party service or a person |
+| `vision.ocr` | `POST /api/v1/ocr` (the AI fallback also needs a person or this grant) | a first-party service or a person |
+| `vision.compare` | `POST /api/v1/compare` | a first-party service or a person |
+| `vision.ui.check` | `POST /api/v1/ui-check`, `GET /api/v1/ui-checks/:id` | **OpenVibe.Design's Arena**, or a person |
+
+Outgoing: **`ai.run.create`** to OpenVibe.AI (audience `openvibe.ai`, namespace `ai.*`), minted by Network for the
+client `vision`. No new AI capability, no provider key, no direct paid provider.
+
+## API
+
+JSON under `/api/v1`; errors are `application/problem+json` with a `code`. An image is a multipart `image` field, a
+JSON `image` data URL, or a JSON `image_url` public URL. A person is the `vision_at` session cookie or a Network user
+token as a Bearer. A session write must come from `https://openvibe.vision` (403 `vision.cross_site`). A service token
+must hold the route's capability (403 `capability.denied`).
+
+| Route | What it does |
+|---|---|
+| `POST /api/v1/ask` (alias `POST /api/v1/describe`) | `{ task?, question, mode?, lang?, model?, image_url? }` or multipart `image`; returns `{ answer }`. A read question is local-first, otherwise AI. |
+| `POST /api/v1/ocr` | multipart/data URL/URL; `{ fallback?, lang?, model? }`; returns the OCR row with `text`, `answer.confidence`, `answer.words`, cost 0. |
+| `POST /api/v1/compare` | `{ task?, question?, models?: string[] }` plus an image; returns `{ answer, compare }`, one row per engine. |
+| `POST /api/v1/ui-check` | `{ html }`, `{ url }`, or a screenshot plus `{ viewport: { w, h } }`; returns `{ check }` with `findings`, `counts`, `severity`, `warnings`. |
+| `GET /api/v1/answers/:id` | one saved answer: its owner, or a service holding `vision.ask`. |
+| `GET /api/v1/ui-checks/:id` | one saved UI check: its owner, or a service holding `vision.ui.check`. |
+| `GET /api/v1/models` | AI's offered `provider:model` hints and the local engine. No subject data. |
+| `GET /api/v1/benchmarks?task=&limit=` | aggregate compare samples: task, engine, provider, model, samples, mean latency, mean cost. No subject or answer text. |
+| `GET /api/v1/ping`, `/api/health`, `/api/ready`, `/release.json`, `/metrics` | service endpoints; readiness and metrics are truthful, metrics loopback-only. |
+
+Example — the call Design's Arena makes:
+
+```sh
+curl -s -X POST https://openvibe.vision/api/v1/ui-check \
+ -H "Authorization: Bearer $VISION_SERVICE_TOKEN" \
+ -H 'Content-Type: application/json' \
+ -d '{"html":"<!doctype html><style>.card{width:1400px}</style><div class=card>x</div>","viewport":{"w":390,"h":844}}'
+```
+
+Answer (abridged):
+
+```json
+{ "check": {
+ "engine": "static-html",
+ "viewport": { "w": 390, "h": 844 },
+ "counts": { "overflow": 1, "contrast": 1 },
+ "severity": { "high": 1, "medium": 1 },
+ "findings": [
+ { "kind": "overflow", "severity": "medium", "selector": "div.card", "detail": "the box is 1400px wide and reaches 1400px in a 390px viewport", "evidence": { "width": 1400, "right": 1400, "viewport": 390 } }
+ ]
+}}
+```
+
+Validation refusals: `vision.invalid`, `vision.invalid_task`, `vision.invalid_mode`, `vision.question_required`,
+`vision.image_required`, `vision.image_too_large`, `vision.image_private_url`, `vision.image_unsupported`,
+`vision.ocr_unavailable`, `vision.ai_unavailable`, `vision.not_found`, `vision.rate_limited`.
+
+## Pages
+
+All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, semantic HTML
+with stable `vi-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in
+person is `private, no-store`.
+
+| Page | What it is |
+|---|---|
+| `/` | the room: hero, ask, compare and UI-check scenes, and a try section |
+| `/ask` | upload or URL + a question (POST → `/a/:id`) |
+| `/ocr` | local OCR, with an inline reading for a guest and a saved answer for a person |
+| `/compare` | local OCR vs AI model hints (POST → `/a/:id`) |
+| `/ui-check` | HTML, URL or screenshot; inline for a guest, saved at `/ui/:id` for a person |
+| `/a/:id` | one saved answer (owner or staff) |
+| `/ui/:id` | one saved UI check (owner or staff) |
+| `/developers` | the API, capabilities and what leaves the host |
+| 404 | the not-found page |
+
+`public/css/vision.css` and `public/js/vision.js` are one-comment placeholder files: the pages work without them and
+the main session designs them. `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list only the public
+pages; `/a/`, `/ui/`, `/auth/`, `/api/` and `/internal` are not indexed.
+
+## The v1 proof
+
+`node scripts/proof.js` is offline: it renders a receipt, reads it with local Tesseract, and checks a page with a
+planted 1400 px overflow, a clipped nowrap line and low contrast. It exits non-zero if the local reader misses the
+total or the UI check misses a fault. The tests do the same over the API, and `test/compare.test.js` runs two AI
+hints and the local reader on one image with measured cost and latency.
+
+## Run and test
+
+```sh
+npm install
+npm test # PGlite; every file in its own process, no network needed
+npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
+OV_ALLOW_SUITE=1 npm test
+npm start # PORT 5130, loopback; DATABASE_URL or an embedded PGlite in development
+node scripts/proof.js # local OCR + a planted UI overflow, no network and no model
+```
+
+The tests use a mock Network, a mock OpenVibe.AI and local image/page fixtures: migration, SSO and readiness, the
+API and its capability guards, local OCR and its fallback, compare and benchmark aggregation, the UI check (a
+planted overflow, cut-off line, low contrast, overlap and layering fault), noLooks, pages/cache, account
+export/deletion and the image SSRF guard.
+
+## Deploy
+
+`deploy/systemd/openvibe-vision.service` (127.0.0.1:5130, `/etc/openvibe/vision.env`) and
+`deploy/nginx/openvibe.vision.conf` (TLS, www → apex, `/auth/me` on the API zone, a forms zone for `/ask`, `/ocr`,
+`/compare` and `/ui-check`, `/internal` and `/metrics` 404). Not deployed by this change.
+
+## License
+
+AGPL-3.0-only. See [LICENSE](LICENSE).
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.vision, openvibe.design, 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.Vision holds
+
+- Saved answers: the question, the cleaned answer text, the engine/model/provider/route that answered, the reason,
+ the cost, the latency, the image sha256, the media type and the byte count. Never the image bytes.
+- Compare samples: the same model/provenance/cost/latency per engine. Aggregate benchmark rows have no subject.
+- UI checks: source (html, url or screenshot), the viewport, the findings and the summary. Never the screenshot.
+- A person's Network session tokens only in httpOnly cookies (`vision_at`, `vision_rt`); no token is stored in the
+ database, rendered into a page or logged.
+- No provider key. AI calls use the service's own Network client-credentials token and OpenVibe.AI's routing.
+
+## Image inputs and the UI check
+
+An image URL is fetched only after every resolved address is checked: loopback, link-local, private and
+unique-local addresses are refused, redirects are re-checked, the content type must be an image and the size is
+bounded. `VISION_ALLOW_PRIVATE_URLS=1` exists for tests and a deliberately isolated deployment; it is off in
+production. A UI check URL goes through the same public-address guard, executes no JavaScript and fetches no remote
+stylesheet. The screenshot path writes a temporary file for Tesseract and removes it in a finally block.
+
+The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
+`/internal` and `/metrics`. A service token is judged on its own signature, audience and capability grant; an
+unknown or forged token is refused, never downgraded to the session.
+
+## No faces
+
+Vision never identifies or recognises faces, never names a person, and never describes a person's looks in public AI text. The model prompt
+carries the rule and every AI answer is passed through `noLooks`; an answer that names looks is replaced. A report
+that either rule was broken is a security report for this repository.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+{
+ "repository": "OpenVibers/OpenVibe.Vision",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Vision v1 (plan 2026-10-28 §5.8): ask a picture, compare models side by side, OCR with a local engine first, and the UI check as an API other services call",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9, Tesseract 5, sharp",
+ "port": "5130",
+ "domain": "openvibe.vision",
+ "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"
+ ],
+ "capabilities": [
+ "vision.ask",
+ "vision.ocr",
+ "vision.compare",
+ "vision.ui_check"
+ ],
+ "outboundCapabilities": [
+ "ai.run.create"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "summary": "OpenVibe.Vision v1 sees with the best model for the job. A read question goes to the local Tesseract reader first; if the local engine cannot answer, OpenVibe.AI's routed vision model does, and every answer names the model, provider, route, cost and latency and says why it was chosen. A compare runs the same image through the local reader and up to three AI model hints side by side. The UI check reads HTML, a public URL or a screenshot locally and returns overflow, cut-off text, low contrast, overlap and layering findings as JSON; Design's Arena can call POST /api/v1/ui-check with a service token holding vision.ui_check. No image bytes are stored, no provider key exists here, and no face recognition is ever used.",
+ "works": [
+ "Ask a picture: multipart upload, data URL or public image URL; task ask/describe/identify/count/translate/ocr; auto/local/ai modes",
+ "Local-first OCR: Tesseract 5 on this host, word boxes and mean confidence, zero cost, an optional AI fallback only after an empty local pass",
+ "OpenVibe.AI gateway: first-party service token (ai.run.create, namespace ai.*), OpenAI-compatible image message, AI provenance, quota and cost, no direct provider",
+ "Compare: local OCR plus one AI run per model hint, requested vs answered model, measured latency and AI cost, sample rows",
+ "UI check: static HTML/CSS pass (overflow, cut-off, contrast, overlap, layering), public URL pass with SSRF guard, screenshot pass through OCR boxes and sharp pixels",
+ "No looks: public AI text is passed through noLooks; an answer that names looks is replaced by a neutral sentence",
+ "No face recognition: not implemented, not called, stated on the pages",
+ "Answers, compare samples and UI checks stored without image bytes; account export/deletion over all three",
+ "Network sign-in as OAuth client vision (PKCE S256, vision_at/vision_rt/vision_oauth), truthful readiness, loopback metrics",
+ "Server-rendered pages with stable vi- ids/classes, discovery artifacts, deploy unit and nginx vhost, CI"
+ ],
+ "notYet": [
+ "Public Vision Arena leaderboards and public test sets (v2); v1 stores aggregate compare samples and exposes them without subjects",
+ "Video and computer-use benchmarks (v2/v3)",
+ "OpenCV jobs placed on OpenVibe.Run; v1 uses Tesseract on this host and sharp for pixels",
+ "A real browser-rendered UI pass (JavaScript execution, getBoundingClientRect, computed styles); v1 is a documented static pass plus a screenshot pass",
+ "Search documents/outbox: v1 has no public records to index",
+ "A per-person history page; saved answers are reachable by id for their owner and staff",
+ "The capability manifests and grants; the main session registers vision.* and grants them"
+ ],
+ "deployed": false,
+ "features": [
+ "ask a picture",
+ "local-first OCR",
+ "OpenVibe.AI routed answers",
+ "model compare with cost and latency",
+ "UI check: overflow, cut-off, contrast, overlap, layering",
+ "noLooks and no face recognition",
+ "account export and deletion",
+ "Network sign-in",
+ "server-rendered pages",
+ "discovery",
+ "deploy files"
+ ],
+ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-vision.service and deploy/nginx/openvibe.vision.conf",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.vision — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.vision.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5130, systemd unit openvibe-vision) 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; private, no-store for
+## anything drawn for a signed-in person or a saved answer). Never log request bodies or image bytes here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=vision_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=vision_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=vision_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.vision www.openvibe.vision;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.vision$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.vision;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.vision/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.vision/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.vision$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.vision;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.vision/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.vision/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.vision.access.log;
+ error_log /var/log/nginx/openvibe.vision.error.log;
+
+ # Images are capped at 8 MB by the app (16 MB as a base64 JSON body) and HTML at 1 MB; leave room for the headers.
+ client_max_body_size 16m;
+
+ 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=vision_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5130;
+ }
+
+ location /auth/ {
+ limit_req zone=vision_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5130;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=vision_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5130;
+ }
+
+ # ── Forms: ask, OCR, compare and the UI check ───────────
+ location ~ ^/(ask|ocr|compare|ui-check)/?$ {
+ limit_req zone=vision_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5130;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5130/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5130/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:5130;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+[Unit]
+Description=OpenVibe.Vision — see with the best model for the job.
+# Network (sign-in, JWKS, AI tokens, account events) and AI should be up first, but Vision degrades honestly
+# without them: local OCR and the UI check serve, and AI-dependent routes answer 503 (see /api/ready).
+After=network.target postgresql.service pgbouncer.service valkey-server.service openvibe-ai.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service openvibe-ai.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.vision
+EnvironmentFile=/etc/openvibe/vision.env
+Environment=NODE_ENV=production
+Environment=PORT=5130
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.vision/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.Vision's own database is PostgreSQL on the host's data role; the code tree stays read-only.
+# Tesseract is read from /usr/bin and its language data from /usr/share/tesseract-ocr (read-only).
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+-- phase: expand
+-- OpenVibe.Vision v1: ask a picture, compare engines side by side, OCR with the local reader, and the UI check.
+-- An answer and a UI check are a person's or a caller's own row, not the network's public record: v1 has no public
+-- objects, so there is no Search outbox and no tombstone. No image bytes are ever stored: only a sha256 and the
+-- declared media type. A model, provider, route and cost are recorded because every answer must say which model
+-- answered and why.
+
+CREATE TABLE vision_answers (
+ id text COLLATE "C" PRIMARY KEY, -- vis_ + 26 Crockford base32 characters (ULID)
+ subject text COLLATE "C", -- usr_… for a person; null for a service caller
+ requester text COLLATE "C" NOT NULL, -- user:usr_… | service:design | anonymous
+ task text COLLATE "C" NOT NULL, -- ask | describe | ocr | compare
+ question text,
+ mode text COLLATE "C" NOT NULL DEFAULT 'auto', -- auto | local | ai
+ source text COLLATE "C" NOT NULL, -- upload | url | data
+ source_url text, -- the public URL a caller named, never a secret
+ image_sha256 text COLLATE "C" NOT NULL,
+ image_mime text COLLATE "C" NOT NULL,
+ image_bytes integer NOT NULL,
+ text text, -- the noLooks-cleaned answer
+ answer jsonb NOT NULL DEFAULT '{}'::jsonb, -- model details, OCR lines, compare rows
+ model text COLLATE "C",
+ provider text COLLATE "C",
+ route text COLLATE "C",
+ reason text, -- why this engine/model was chosen
+ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,
+ latency_ms integer NOT NULL DEFAULT 0,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT vision_answers_id CHECK (id ~ '^vis_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_answers_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_answers_requester CHECK (length(requester) BETWEEN 1 AND 160),
+ CONSTRAINT vision_answers_task CHECK (task IN ('ask', 'describe', 'ocr', 'compare')),
+ CONSTRAINT vision_answers_mode CHECK (mode IN ('auto', 'local', 'ai')),
+ CONSTRAINT vision_answers_source CHECK (source IN ('upload', 'url', 'data')),
+ CONSTRAINT vision_answers_sha CHECK (image_sha256 ~ '^[0-9a-f]{64}$'),
+ CONSTRAINT vision_answers_mime CHECK (image_mime ~ '^image/[a-z0-9.+-]+$'),
+ CONSTRAINT vision_answers_bytes CHECK (image_bytes BETWEEN 1 AND 20971520),
+ CONSTRAINT vision_answers_question CHECK (question IS NULL OR length(question) <= 2000),
+ CONSTRAINT vision_answers_text CHECK (text IS NULL OR length(text) <= 20000),
+ CONSTRAINT vision_answers_cost CHECK (cost_usd >= 0),
+ CONSTRAINT vision_answers_latency CHECK (latency_ms >= 0)
+);
+CREATE INDEX vision_answers_by_subject ON vision_answers (subject, created_at DESC);
+CREATE INDEX vision_answers_by_requester ON vision_answers (requester, created_at DESC);
+CREATE INDEX vision_answers_by_hash ON vision_answers (image_sha256, created_at DESC);
+
+-- One row per engine in a side-by-side compare: local Tesseract and/or one AI run per requested model. The
+-- requested model is what the caller asked for; model/provider are what OpenVibe.AI's provenance says answered,
+-- so a fallback is never mislabelled. These rows feed GET /api/v1/benchmarks (aggregate, no subjects).
+CREATE TABLE vision_compare_samples (
+ id text COLLATE "C" PRIMARY KEY, -- vcm_ + ULID
+ answer_id text COLLATE "C" REFERENCES vision_answers (id) ON DELETE CASCADE,
+ subject text COLLATE "C",
+ task text COLLATE "C" NOT NULL DEFAULT 'ask',
+ engine text COLLATE "C" NOT NULL, -- local | ai
+ requested_model text COLLATE "C",
+ model text COLLATE "C",
+ provider text COLLATE "C",
+ route text COLLATE "C",
+ ok boolean NOT NULL DEFAULT false,
+ text text,
+ cost_usd numeric(12, 6) NOT NULL DEFAULT 0,
+ latency_ms integer NOT NULL DEFAULT 0,
+ error_code text COLLATE "C",
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT vision_compare_samples_id CHECK (id ~ '^vcm_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_compare_samples_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_compare_samples_task CHECK (task IN ('ask', 'describe', 'ocr', 'translate', 'identify', 'count')),
+ CONSTRAINT vision_compare_samples_engine CHECK (engine IN ('local', 'ai')),
+ CONSTRAINT vision_compare_samples_cost CHECK (cost_usd >= 0),
+ CONSTRAINT vision_compare_samples_latency CHECK (latency_ms >= 0)
+);
+CREATE INDEX vision_compare_samples_recent ON vision_compare_samples (created_at DESC);
+CREATE INDEX vision_compare_samples_by_model ON vision_compare_samples (provider, model, created_at DESC);
+CREATE INDEX vision_compare_samples_by_subject ON vision_compare_samples (subject, created_at DESC);
+
+-- The UI check: a static layout pass over HTML/URL or a screenshot pass through OCR boxes and pixel sampling.
+-- Findings are data (kind, severity, selector, detail, evidence); no screenshot is stored.
+CREATE TABLE vision_ui_checks (
+ id text COLLATE "C" PRIMARY KEY, -- vuc_ + ULID
+ subject text COLLATE "C",
+ requester text COLLATE "C" NOT NULL,
+ source text COLLATE "C" NOT NULL, -- html | url | screenshot
+ url text,
+ image_sha256 text COLLATE "C",
+ viewport_w integer NOT NULL DEFAULT 1280,
+ viewport_h integer NOT NULL DEFAULT 800,
+ engine text COLLATE "C" NOT NULL, -- static-html | screenshot
+ findings jsonb NOT NULL DEFAULT '[]'::jsonb,
+ summary jsonb NOT NULL DEFAULT '{}'::jsonb,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT vision_ui_checks_id CHECK (id ~ '^vuc_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_ui_checks_subject CHECK (subject IS NULL OR subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT vision_ui_checks_source CHECK (source IN ('html', 'url', 'screenshot')),
+ CONSTRAINT vision_ui_checks_engine CHECK (engine IN ('static-html', 'screenshot')),
+ CONSTRAINT vision_ui_checks_viewport CHECK (viewport_w BETWEEN 160 AND 10000 AND viewport_h BETWEEN 160 AND 10000),
+ CONSTRAINT vision_ui_checks_sha CHECK (image_sha256 IS NULL OR image_sha256 ~ '^[0-9a-f]{64}$'),
+ CONSTRAINT vision_ui_checks_url CHECK (url IS NULL OR url ~ '^https?://')
+);
+CREATE INDEX vision_ui_checks_by_subject ON vision_ui_checks (subject, created_at DESC);
+
+-- 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,1970 @@
@@ -0,0 +1,1970 @@
+{
+ "name": "openvibe-vision",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-vision",
+ "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",
+ "multer": "^2.5.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",
+ "sharp": "^0.33.5"
+ },
+ "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/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
+ "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
+ "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
+ "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
+ "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
+ "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
+ "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
+ "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
+ "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
+ "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
+ "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
+ "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "cpu": [
+ "arm"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.0.5"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
+ "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
+ "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
+ "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "glibc"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
+ "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "cpu": [
+ "arm64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
+ "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "cpu": [
+ "x64"
+ ],
+ "libc": [
+ "musl"
+ ],
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
+ "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/runtime": "^1.2.0"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
+ "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
+ "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "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/append-field": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
+ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
+ "license": "MIT"
+ },
+ "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/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "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/color": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
+ "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1",
+ "color-string": "^1.9.0"
+ },
+ "engines": {
+ "node": ">=12.5.0"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/color-string": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
+ "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "^1.0.0",
+ "simple-swizzle": "^0.2.2"
+ }
+ },
+ "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/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "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/is-arrayish": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
+ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
+ "license": "MIT"
+ },
+ "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/multer": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
+ "integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
+ "license": "MIT",
+ "dependencies": {
+ "append-field": "^1.0.0",
+ "busboy": "^1.6.0",
+ "type-is": "^1.6.18"
+ },
+ "engines": {
+ "node": ">= 10.16.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "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/sharp": {
+ "version": "0.33.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
+ "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "color": "^4.2.3",
+ "detect-libc": "^2.0.3",
+ "semver": "^7.6.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.33.5",
+ "@img/sharp-darwin-x64": "0.33.5",
+ "@img/sharp-libvips-darwin-arm64": "1.0.4",
+ "@img/sharp-libvips-darwin-x64": "1.0.4",
+ "@img/sharp-libvips-linux-arm": "1.0.5",
+ "@img/sharp-libvips-linux-arm64": "1.0.4",
+ "@img/sharp-libvips-linux-s390x": "1.0.4",
+ "@img/sharp-libvips-linux-x64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
+ "@img/sharp-linux-arm": "0.33.5",
+ "@img/sharp-linux-arm64": "0.33.5",
+ "@img/sharp-linux-s390x": "0.33.5",
+ "@img/sharp-linux-x64": "0.33.5",
+ "@img/sharp-linuxmusl-arm64": "0.33.5",
+ "@img/sharp-linuxmusl-x64": "0.33.5",
+ "@img/sharp-wasm32": "0.33.5",
+ "@img/sharp-win32-ia32": "0.33.5",
+ "@img/sharp-win32-x64": "0.33.5"
+ }
+ },
+ "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/simple-swizzle": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
+ "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
+ "license": "MIT",
+ "dependencies": {
+ "is-arrayish": "^0.3.1"
+ }
+ },
+ "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/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "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/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD",
+ "optional": true
+ },
+ "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,39 @@
@@ -0,0 +1,39 @@
+{
+ "name": "openvibe-vision",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Vision — see with the best model for the job.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "proof": "node scripts/proof.js",
+ "test": "node test/run.js",
+ "test:pg": "VISION_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",
+ "multer": "^2.5.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",
+ "sharp": "^0.33.5"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+/* OpenVibe.Vision: 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. Results show which engine answered, its cost and its time. */
+*, *::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; }
+.vi { display: block; }
+.vi a { color: var(--room-strong); }
+.vi .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.vi-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.vi-narrow { max-width: 620px; }
+.vi-page-pad { padding-top: clamp(28px, 6vw, 64px); }
+.vi h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.vi-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.vi-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
+.vi-page-h h1, .vi-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; }
+.vi-lede, .vi-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.vi-fine, .vi-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.vi-section { margin-top: clamp(28px, 4vw, 44px); }
+.vi-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.vi-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
+.vi-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
+.vi-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
+.vi-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; }
+.vi-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
+.vi-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
+.vi-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
+.vi-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.vi-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); }
+.vi-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
+.vi-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
+.vi-empty p { margin: 0; line-height: 1.5; }
+.vi-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+
+
+.vi-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.vi-form { display: grid; gap: 14px; }
+.vi-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
+.vi-field input, .vi-field select, .vi-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; }
+.vi-field input:focus, .vi-field select:focus, .vi-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
+.vi-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
+.vi-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.vi-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.vi-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.vi-eps { display: grid; gap: 10px; margin: 12px 0 0; }
+.vi-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.vi-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
+.vi-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+
+.vi-note.is-warm { background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); border-color: color-mix(in srgb, var(--warning, #f59e0b) 35%, transparent); }
+.vi-btn[disabled] { opacity: .5; cursor: not-allowed; }
+.vi-field-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
+.vi-field input[type=file] { padding: 8px; }
+.vi-two-col { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 22px; align-items: start; }
+.vi-try { padding: 20px; border-radius: 20px; background: color-mix(in srgb, var(--room-accent) 7%, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); }
+
+/* The aperture: two engines racing on one image */
+.vi-race { position: relative; width: min(100%, 440px); aspect-ratio: 420 / 260; margin: 0 auto; color: var(--room-accent); }
+.vi-race-img { position: absolute; inset: 0; width: 100%; height: 100%; }
+html.ov-motion .vi-race-img path { stroke-dasharray: 420; animation: vi-draw 1.8s cubic-bezier(.2,.7,.2,1) both; }
+@keyframes vi-draw { from { stroke-dashoffset: 420; } }
+.vi-race-box { position: absolute; display: grid; gap: 2px; padding: 8px 12px; border-radius: 12px; border: 2px solid currentColor; background: color-mix(in srgb, var(--bg-card, #0f1729) 88%, transparent); color: var(--text-primary, #e6edf7); font-size: .82rem; }
+.vi-race-box b { color: var(--room-strong); }
+.vi-race-box small { font-variant-numeric: tabular-nums; color: var(--text-muted, #7386a3); }
+.vi-race-a { left: 6%; top: 12%; border-color: color-mix(in srgb, var(--room-accent) 55%, transparent); }
+.vi-race-b { right: 6%; bottom: 14%; border-color: var(--room-accent); }
+html.ov-motion .vi-race-box { animation: vi-pop .6s cubic-bezier(.2,.9,.3,1.2) both; } html.ov-motion .vi-race-b { animation-delay: .35s; }
+@keyframes vi-pop { from { opacity: 0; scale: .85; } }
+.vi-race-tag { position: absolute; left: 50%; bottom: -4px; translate: -50% 0; padding: 3px 10px; border-radius: 999px; font-size: .72rem; font-weight: 800; color: var(--room-on); background: var(--room-fill); white-space: nowrap; }
+
+/* Scenes' demos */
+.vi-demo-receipt { display: grid; gap: 4px; width: min(100%, 300px); margin: 0 auto; padding: 18px 20px; border-radius: 6px; background: #fbfaf6; color: #222; font: 500 .9rem/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; box-shadow: 0 12px 30px rgba(0, 0, 0, .25); }
+.vi-demo-receipt strong { margin-top: 6px; padding-top: 6px; border-top: 1px dashed #999; font-size: 1.05rem; }
+.vi-demo-receipt small { color: #6b6b6b; font-size: .74rem; }
+.vi-demo-kicker { justify-self: start; padding: 2px 8px; border-radius: 6px; font: 800 .68rem/1.4 Inter, sans-serif; letter-spacing: .08em; color: #fff; background: #6b4ab0; }
+.vi-demo-table, .vi-table { width: 100%; border-collapse: collapse; font-size: .88rem; font-variant-numeric: tabular-nums; }
+.vi-demo-table caption, .vi-table caption { text-align: left; padding-bottom: 8px; color: var(--text-muted, #7386a3); font-size: .8rem; }
+.vi-demo-table th, .vi-demo-table td, .vi-table th, .vi-table td { padding: 9px 10px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); vertical-align: top; overflow-wrap: anywhere; }
+.vi-demo-table th, .vi-table th { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.vi-demo-table { background: var(--bg-card, #0f1729); border-radius: 14px; overflow: hidden; border: 1px solid var(--border, #1f2d47); }
+.vi-table-wrap { overflow-x: auto; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); }
+.vi-demo-check { display: grid; gap: 10px; }
+.vi-demo-page { position: relative; height: 120px; overflow: hidden; border-radius: 12px; border: 2px dashed color-mix(in srgb, var(--error, #ef4444) 55%, transparent); background: var(--bg-card, #0f1729); }
+.vi-demo-overflow { position: absolute; left: 12px; top: 14px; width: 140%; padding: 10px; border-radius: 8px; background: color-mix(in srgb, var(--room-accent) 22%, transparent); font-size: .8rem; }
+.vi-demo-clip { position: absolute; left: 12px; bottom: 16px; width: 55%; white-space: nowrap; overflow: hidden; font-weight: 800; color: #8a8f98; }
+.vi-demo-check ul, .vi-warnings { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
+.vi-demo-check li, .vi-finding { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); font-size: .86rem; }
+.vi-demo-check li.is-bad b, .vi-finding b { color: var(--error-text, #f87171); }
+.vi-demo-check li span, .vi-finding span { color: var(--text-secondary, #96a7c2); }
+
+/* Results */
+.vi-result, .vi-answer-card, .vi-check { display: grid; gap: 12px; margin-top: 18px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-top: 4px solid var(--room-accent); }
+.vi-question { margin: 0; font-weight: 800; font-size: 1.05rem; }
+.vi-answer-text { margin: 0; font-size: 1.05rem; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
+.vi-ocr-text, .vi-sample-text, .vi-code { margin: 0; padding: 14px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font: 500 .86rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; max-height: 420px; overflow: auto; }
+.vi-models { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; list-style: none; }
+.vi-models li { padding: 4px 10px; border-radius: 999px; font-size: .8rem; font-weight: 700; background: color-mix(in srgb, var(--room-accent) 14%, transparent); color: var(--room-strong); }
+.vi-warnings li { padding: 8px 12px; border-radius: 10px; font-size: .86rem; background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent); }
+
+@media (max-width: 860px) { .vi-two-col { grid-template-columns: minmax(0, 1fr); } }
+@media (max-width: 759px) { .sc-room-pic .vi-race-box, .sc-room-pic .vi-race-tag { display: none; } }
+@media (prefers-reduced-motion: reduce) { .vi-race-img path, .vi-race-box { animation: none !important; } }
+html[data-ov-motion=reduced] .vi-race-img path, html[data-ov-motion=reduced] .vi-race-box { animation: none !important; }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="OpenVibe.Vision"><path d="M4 32c6-10 15-16 28-16s22 6 28 16c-6 10-15 16-28 16S10 42 4 32Z" fill="none" stroke="#7444B4" stroke-width="3"/><circle cx="32" cy="32" r="9" fill="#7444B4"/><circle cx="35" cy="29" r="3" fill="#fff"/></svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+/* OpenVibe.Vision enhancement layer (main session). The v1 forms and results are complete without JavaScript. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * The v1 proof, offline: render a receipt, read it with the local Tesseract engine, and check a page with a
+ * planted overflow and low contrast. No database, no network, no model. A model call is deliberately not made:
+ * the first proof is that the cheap local path and the UI check produce a usable artifact on their own.
+ *
+ * node scripts/proof.js human-readable
+ * node scripts/proof.js --json one JSON document
+ */
+const sharp = require('sharp');
+const { load } = require('../server/config');
+const { createOcr } = require('../server/vision/ocr');
+const { checkHtml } = require('../server/vision/ui-check');
+
+function receipt() {
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="720" height="360">
+<rect width="100%" height="100%" fill="#ffffff"/>
+<text x="28" y="62" font-family="DejaVu Sans Mono" font-size="34" fill="#111111">OPENVIBE MARKET</text>
+<text x="28" y="120" font-family="DejaVu Sans Mono" font-size="26" fill="#111111">2026-10-28 14:02</text>
+<text x="28" y="176" font-family="DejaVu Sans Mono" font-size="28" fill="#111111">Oat milk 3.40</text>
+<text x="28" y="222" font-family="DejaVu Sans Mono" font-size="28" fill="#111111">Bread 2.10</text>
+<text x="28" y="294" font-family="DejaVu Sans Mono" font-size="34" fill="#111111">TOTAL 5.50</text>
+</svg>`;
+ return sharp(Buffer.from(svg)).png().toBuffer();
+}
+
+const PLANTED = `<!doctype html><html><head><style>
+body{background:#fff;color:#9a9a9a;font-size:16px}
+.wide{width:1400px;height:40px;background:#eee}
+.cut{width:120px;overflow:hidden;white-space:nowrap}
+</style></head><body><p>Low contrast words here</p><div class="wide">wide</div><div class="cut">This line will be cut off</div></body></html>`;
+
+(async () => {
+ const config = load({ NODE_ENV: 'development', VISION_TESSERACT: process.env.VISION_TESSERACT || 'tesseract' });
+ const ocr = createOcr({ config });
+ const image = await receipt();
+ const read = await ocr.recognize(image);
+ const ui = checkHtml(PLANTED, { viewport: { w: 390, h: 844 } });
+ const out = {
+ okay: Boolean(read.ok && /TOTAL\s+5\.50/.test(read.text || '')) && ui.counts.overflow >= 1 && ui.counts.contrast >= 1 && ui.counts.cut_off >= 1,
+ ocr: { engine: 'tesseract', text: read.text || null, confidence: read.confidence == null ? null : read.confidence, ms: read.ms == null ? null : read.ms, cost_usd: 0, error: read.ok ? null : read.code },
+ ui: { engine: ui.engine, viewport: ui.viewport, counts: ui.counts, summary: ui.summary, findings: ui.findings },
+ model_calls: 0,
+ };
+ if (process.argv.includes('--json')) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`);
+ else {
+ console.log('OpenVibe.Vision v1 proof (offline)');
+ console.log(` local OCR: ${read.ok ? 'ok' : read.code} · ${read.ms} ms · confidence ${read.confidence}`);
+ console.log(` text: ${JSON.stringify(read.text || '')}`);
+ console.log(` UI check: ${ui.summary}`);
+ for (const f of ui.findings) console.log(` - ${f.kind} (${f.severity}) ${f.selector}: ${f.detail}`);
+ console.log(` model calls: 0`);
+ }
+ if (!out.okay) process.exitCode = 1;
+})().catch((err) => { console.error(err && err.message ? err.message : err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -0,0 +1,175 @@
+'use strict';
+
+/**
+ * OpenVibe.Vision — Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database, a mock Network and a mock OpenVibe.AI.
+ *
+ * /, /ask, /ocr, /compare, /ui-check, /a/:id, /ui/:id, /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)
+ *
+ * Every model call goes through OpenVibe.AI; the local OCR engine and the UI check run on this host. No provider
+ * key, no paid provider and no image bytes are stored.
+ */
+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 { createOcr } = require('./vision/ocr');
+const { createAi } = require('./vision/ai');
+const { createVision } = require('./vision/service');
+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, AI and public URLs), 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 store = createStore(s);
+ const ocr = opts.ocr || createOcr({ config, log });
+ const ai = opts.ai || createAi({ config, fetchImpl, log });
+ const vision = createVision({ config, s, ocr, ai, store, log });
+
+ // 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: Vision cannot answer account events'); });
+
+ const ctx = { config, s, keys, sso, principal, store, ocr, ai, vision, accountData, accountSend, log, fetchImpl };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'vision', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'vision', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'vision', 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:', 'blob:', '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-vision', name: 'OpenVibe.Vision', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, valkey, ai, ocr });
+ 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: 'vision', service: 'vision', host: 'openvibe.vision', name: 'OpenVibe.Vision', 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.VISION_API_RATE_LIMIT_PER_MIN) || 120, 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.VISION_RATE_LIMIT_PER_MIN) || 180, 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.Vision]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's Bearer token (the API).
+ *
+ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
+ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
+ * this module: token.no_key answers 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Vision] 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.Vision] 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.Vision] 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 `vision`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
+ * (?silent=1 adds prompt=none; ?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
+ *
+ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives
+ * in an httpOnly cookie (vision_at) no script can read; the refresh token (vision_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 = 'vision_at';
+const REFRESH_COOKIE = 'vision_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'vision_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function ownOrigins(config) {
+ const out = new Set();
+ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
+ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
+ }
+ return out;
+}
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next, config) {
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null;
+ try { u = new URL(next); } catch { return '/'; }
+ if (!ownOrigins(config).has(u.origin)) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ /**
+ * A Vision session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
+ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ res.set('Clear-Site-Data', '"cache"');
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Vision] 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.Vision?</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,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * OpenVibe.Vision configuration. Every value comes from the environment (production: /etc/openvibe/vision.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, 5130);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.vision' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'vision',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.VISION_DRILL === '1' || env.VISION_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.VISION_LIMITS_MINUTE, 60)),
+ hour: Math.max(1, int(env.VISION_LIMITS_HOUR, 600)),
+ },
+
+ // 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.VISION_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:vision:' },
+
+ // 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.VISION_AUDIENCE || 'openvibe.vision',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'vision',
+ 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.VISION_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.VISION_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.VISION_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.VISION_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.VISION_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // OpenVibe.AI: the model gateway. Vision never talks to a model provider directly and never holds a
+ // provider key. Calls are first-party service-token runs (ai.run.create, namespace ai.*), so AI's own
+ // routing, quotas, cache, provenance and cost accounting apply. With no client secret AI is off and the
+ // local engines alone answer.
+ ai: {
+ url: trim(env.OV_AI_URL || env.OV_AI_INTERNAL_URL || 'https://ai.openvibe.services'),
+ audience: env.VISION_AI_AUDIENCE || 'openvibe.ai',
+ scope: env.VISION_AI_SCOPE || 'ai.run.create',
+ namespace: env.VISION_AI_NAMESPACE || 'ai.*',
+ models: String(env.VISION_AI_MODELS || '').split(',').map((x) => x.trim()).filter(Boolean).slice(0, 6),
+ timeoutMs: Math.max(5000, int(env.VISION_AI_TIMEOUT_MS, 45000)),
+ waitMs: Math.max(1000, int(env.VISION_AI_WAIT_MS, 30000)),
+ maxTokens: Math.min(2000, Math.max(64, int(env.VISION_AI_MAX_TOKENS, 700))),
+ temperature: Math.min(1, Math.max(0, Number(env.VISION_AI_TEMPERATURE || 0.1))),
+ },
+
+ // The local OCR engine (Tesseract 5). OpenCV is the classical first step the plan names; Tesseract is the
+ // local reader v1 ships. A missing binary is an honest 503, never a silent cloud fallback.
+ ocr: {
+ bin: env.VISION_TESSERACT || 'tesseract',
+ lang: env.VISION_OCR_LANG || 'eng',
+ timeoutMs: Math.max(2000, int(env.VISION_OCR_TIMEOUT_MS, 20000)),
+ },
+
+ // Image inputs: upload (multipart), a data: URL, or a public https URL fetched with the private-address
+ // guard. No image bytes are stored in the database or written to a page.
+ images: {
+ maxMb: Math.min(20, Math.max(1, int(env.VISION_IMAGE_MAX_MB, 8))),
+ timeoutMs: Math.max(1000, int(env.VISION_IMAGE_TIMEOUT_MS, 10000)),
+ allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',
+ },
+
+ // The UI check: a static layout pass over HTML or a fetched page (no JavaScript executed), plus a
+ // screenshot path through the local OCR boxes and sharp pixel sampling. Nothing leaves this host.
+ ui: {
+ maxHtmlBytes: Math.min(5 * 1024 * 1024, Math.max(64 * 1024, int(env.VISION_UI_MAX_HTML_KB, 1024) * 1024)),
+ timeoutMs: Math.max(1000, int(env.VISION_UI_TIMEOUT_MS, 10000)),
+ allowPrivate: env.VISION_ALLOW_PRIVATE_URLS === '1' || env.VISION_ALLOW_PRIVATE_URLS === 'true',
+ maxFindings: Math.min(500, Math.max(10, int(env.VISION_UI_MAX_FINDINGS, 200))),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Vision's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret: an answer stores the cleaned text, a sha256 of the image and the model
+ * provenance, never the image bytes, a token or a provider 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.Vision] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'vision', 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: 'vision-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'vision', 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,252 @@
@@ -0,0 +1,252 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Vision's JSON API.
+ *
+ * GET /ping
+ * POST /ask one image and a question: local OCR first, then OpenVibe.AI
+ * POST /ocr the local Tesseract reader; `fallback=1` asks AI only when it finds no text
+ * POST /compare local OCR vs one or more AI model hints, side by side, with cost and latency
+ * POST /ui-check overflow, cut-off, contrast, overlap and layering faults from html, url or image
+ * GET /answers/:id one saved answer (its owner, or a service holding vision.ask)
+ * GET /ui-checks/:id one saved UI check (its owner, or a service holding vision.ui.check)
+ * GET /models the AI route's offered provider:model hints (no subject data)
+ * GET /benchmarks aggregate compare samples: model, task, samples, mean latency and cost
+ *
+ * An image is a multipart `image` field, a JSON `image` data URL, or a JSON `image_url` public URL. Errors are
+ * application/problem+json with a code. A person is the vision_at session cookie or a Network user token as a
+ * Bearer; a service token needs exactly the capability named on the route.
+ */
+const express = require('express');
+const multer = require('multer');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { loadImage } = require('../vision/images');
+const { VisionError } = require('../vision/service');
+
+function createApi(ctx) {
+ const { config, s, principal, store, vision, ai, ocr, fetchImpl } = ctx;
+ const r = asyncRouter();
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
+
+ // A data: URL is base64, so an 8 MB image is about 10.7 MB of JSON. Keep the parser above that, below the
+ // multipart cap. The decoded bytes are checked again by loadImage.
+ r.use(express.json({ limit: `${Math.ceil(config.images.maxMb * 1.5) + 2}mb` }));
+ 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 service caller is bounded too.
+ 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) => {
+ const p = req.principal;
+ const who = p && p.kind === 'user' ? `user:${p.subject}` : p && p.kind === 'service' ? `svc:${p.sub}` : null;
+ if (who) {
+ 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, 'vision.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, 'vision.invalid', detail);
+ const isPerson = (req) => req.principal && req.principal.kind === 'user';
+ const isMachine = (req) => req.principal && req.principal.kind === 'service';
+
+ const attachUpload = (req, res, next) => upload.single('image')(req, res, (err) => {
+ if (!err) return next();
+ if (err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'vision.image_too_large', `the image is larger than ${config.images.maxMb} MB`);
+ if (err.code === 'LIMIT_UNEXPECTED_FILE') return invalid(res, 'send one image in the field named image');
+ return next(err);
+ });
+
+ function bodyOf(req) { return req.body && typeof req.body === 'object' ? req.body : {}; }
+ async function imageOf(req) {
+ const b = bodyOf(req);
+ const got = await loadImage({
+ file: req.file && req.file.buffer,
+ imageUrl: b.image_url,
+ image: b.image,
+ config,
+ fetchImpl,
+ });
+ return got;
+ }
+ function viewportOf(req) {
+ const b = bodyOf(req);
+ let v = b.viewport;
+ if (typeof v === 'string') { try { v = JSON.parse(v); } catch { v = null; } }
+ return {
+ w: Math.max(160, Math.min(10000, Number((v && v.w) || b.viewport_w || req.query.width) || 1280)),
+ h: Math.max(160, Math.min(10000, Number((v && v.h) || b.viewport_h || req.query.height) || 800)),
+ };
+ }
+ async function run(res, fn, status = 200) {
+ try {
+ const out = await fn();
+ return res.status(status).json(out);
+ } catch (err) {
+ if (err instanceof VisionError) {
+ if (err.extra && Object.keys(err.extra).length) return res.status(err.status).json({ type: `https://openvibe.vision/problems/${err.code}`, title: 'Request refused', status: err.status, code: err.code, detail: err.detail, ...err.extra });
+ return problem(res, err.status, err.code, err.detail);
+ }
+ if (err && err.code && /^image\./.test(err.code)) {
+ const status2 = err.code === 'image.too_large' ? 413 : err.code === 'image.private_url' ? 422 : 400;
+ return problem(res, status2, `vision.${err.code.replace('.', '_')}`, err.message);
+ }
+ throw err;
+ }
+ }
+
+ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // ── OCR: local first, anonymous local calls allowed ─────
+ r.post('/ocr', attachUpload, principal.optional('vision.ocr', { allowAnonymous: true }), async (req, res) => {
+ const b = bodyOf(req);
+ const fallback = b.fallback === true || b.fallback === '1' || b.fallback === 'true';
+ if (req.principal.kind === 'anonymous' && fallback) return problem(res, 401, 'token.required', 'AI fallback needs a signed-in person or a service token holding vision.ocr');
+ return run(res, async () => {
+ const image = await imageOf(req);
+ return await vision.ocr({
+ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,
+ image, lang: b.lang, fallback, model: b.model,
+ });
+ }, 201);
+ });
+
+ // ── Ask: local OCR answers a read question, else AI ─────
+ // /ask is the product's name; /describe is the operation name in the plan's API list. Same handler.
+ const askHandler = (defaultTask) => async (req, res) => {
+ const b = bodyOf(req);
+ const task = b.task || defaultTask;
+ const mode = b.mode || 'auto';
+ const question = String(b.question || b.q || '').trim();
+ const readish = vision.isReadTask(task, question);
+ if (req.principal.kind === 'anonymous' && mode !== 'local' && !readish) {
+ return problem(res, 401, 'token.required', 'Ask needs a signed-in person or a service token holding vision.ask; anonymous callers can use mode=local or a read question that the local OCR engine answers.');
+ }
+ return run(res, async () => {
+ const image = await imageOf(req);
+ const out = await vision.ask({
+ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,
+ image, task, question, mode: req.principal.kind === 'anonymous' ? 'local' : mode, lang: b.lang, model: b.model,
+ });
+ return { answer: out };
+ }, 201);
+ };
+ r.post('/ask', attachUpload, principal.optional('vision.ask', { allowAnonymous: true }), askHandler('ask'));
+ r.post('/describe', attachUpload, principal.optional('vision.ask', { allowAnonymous: true }), askHandler('describe'));
+
+ // ── Compare: local + AI hints, side by side ────────────
+ r.post('/compare', principal.optional('vision.compare'), attachUpload, async (req, res) => {
+ const b = bodyOf(req);
+ const models = Array.isArray(b.models) ? b.models : (typeof b.models === 'string' && b.models.trim() ? b.models.split(',').map((x) => x.trim()) : []);
+ return run(res, async () => {
+ const image = await imageOf(req);
+ const out = await vision.compare({
+ requester: req.principal.requester || 'anonymous', subject: isPerson(req) ? req.principal.subject : null,
+ image, task: b.task || 'ask', question: b.question || b.q || '', models, lang: b.lang,
+ });
+ return { answer: out, compare: out.compare };
+ }, 201);
+ });
+
+ // ── UI check: HTML, URL or screenshot; local only ──────
+ r.post('/ui-check', attachUpload, principal.optional('vision.ui.check', { allowAnonymous: true }), async (req, res) => {
+ const b = bodyOf(req);
+ const html = typeof b.html === 'string' ? b.html : null;
+ const url = typeof b.url === 'string' ? b.url.trim() : null;
+ if (!html && !url && !req.file && !b.image && !b.image_url) return invalid(res, 'send html, url, an image file, an image data URL, or image_url');
+ if (html && Buffer.byteLength(html, 'utf8') > config.ui.maxHtmlBytes) return problem(res, 413, 'vision.ui_too_large', 'that HTML is larger than the UI check limit');
+ return run(res, async () => {
+ const viewport = viewportOf(req);
+ let out; let image = null;
+ if (req.file || b.image || b.image_url) {
+ image = await imageOf(req);
+ out = await require('../vision/ui-check').check({ imageBuffer: image.buffer, viewport, ocr, maxFindings: config.ui.maxFindings });
+ } else if (html) {
+ out = await require('../vision/ui-check').check({ html, viewport, maxFindings: config.ui.maxFindings });
+ } else {
+ out = await require('../vision/ui-check').check({ url, viewport, config, fetchImpl, maxFindings: config.ui.maxFindings });
+ }
+ const saved = await store.saveUiCheck({
+ subject: isPerson(req) ? req.principal.subject : null,
+ requester: req.principal.requester || 'anonymous',
+ source: out.source, url: out.url || null, imageSha256: image ? image.sha256 : null,
+ viewport: out.viewport, engine: out.engine, findings: out.findings, summary: out,
+ });
+ return { check: { ...saved, findings: out.findings, counts: out.counts, severity: out.severity, warnings: out.warnings, summary_text: out.summary } };
+ }, 201);
+ });
+
+ // ── Reads ──────────────────────────────────────────────
+ r.get('/answers/:id', principal.optional('vision.ask', { allowAnonymous: true }), async (req, res) => {
+ const row = await store.answerById(String(req.params.id || ''));
+ if (!row) return problem(res, 404, 'vision.not_found', 'No such answer.');
+ const owner = await store.answerSubject(row.id);
+ const mine = isPerson(req) && Boolean(owner) && req.principal.subject === owner;
+ const service = isMachine(req);
+ if (!mine && !service) return problem(res, 404, 'vision.not_found', 'No such answer.');
+ res.set('Cache-Control', 'private, no-store');
+ return res.json(row);
+ });
+
+ r.get('/ui-checks/:id', principal.optional('vision.ui.check', { allowAnonymous: true }), async (req, res) => {
+ const row = await store.uiCheckById(String(req.params.id || ''));
+ if (!row) return problem(res, 404, 'vision.not_found', 'No such UI check.');
+ const owner = await store.uiCheckSubject(row.id);
+ const mine = isPerson(req) && Boolean(owner) && req.principal.subject === owner;
+ const service = isMachine(req);
+ if (!mine && !service) return problem(res, 404, 'vision.not_found', 'No such UI check.');
+ res.set('Cache-Control', 'private, no-store');
+ return res.json(row);
+ });
+
+ // ── Model hints and benchmarks (no subject data) ───────
+ let modelCache = null;
+ r.get('/models', async (_req, res) => {
+ if (!modelCache || Date.now() - modelCache.at > 5 * 60_000) {
+ const offered = ai.enabled ? await ai.offeredModels().catch(() => []) : [];
+ modelCache = { at: Date.now(), offered };
+ }
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({
+ ai: ai.enabled,
+ configured: config.ai.models,
+ offered: modelCache.offered,
+ local: ['tesseract'],
+ note: 'provider:model hints; OpenVibe.AI\'s router is the authority and its provenance is what an answer reports',
+ });
+ });
+
+ r.get('/benchmarks', async (req, res) => {
+ const task = typeof req.query.task === 'string' && req.query.task ? req.query.task : null;
+ const limit = req.query.limit == null ? 50 : Number.parseInt(req.query.limit, 10);
+ if (!Number.isFinite(limit) || limit < 1 || limit > 100) return invalid(res, 'limit must be from 1 to 100');
+ if (task && !['ask', 'describe', 'ocr', 'translate', 'identify', 'count'].includes(task)) return invalid(res, 'task must be a compare task');
+ const rows = await store.benchmarks({ task, limit });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ count: rows.length, benchmarks: rows });
+ });
+
+ // multer's own errors: the route-level handler above turns the common ones into problem+json.
+ // eslint-disable-next-line no-unused-vars
+ r.use((err, req, res, next) => {
+ if (err && err.code === 'LIMIT_FILE_SIZE') return problem(res, 413, 'vision.image_too_large', `the image is larger than ${config.images.maxMb} MB`);
+ return next(err);
+ });
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.vision: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The public pages are
+ * for search engines and AI crawlers; sign-in, the API, the internal route, saved answers and saved UI checks are
+ * not (a saved answer belongs to the person who asked). v1 has no public objects, so the sitemap has no per-record
+ * URLs.
+ */
+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.Vision';
+const DESCRIPTION = 'OpenVibe.Vision sees with the best model for the job: ask a picture, read its text with a local OCR engine first, compare models side by side through OpenVibe.AI, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.';
+const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics', '/a/', '/ui/'];
+
+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: 'MultimediaApplication', keywords: 'openvibe vision, image understanding, OCR, tesseract, vision models, UI check, contrast, overflow' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function publicPages() {
+ return [
+ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Vision home', text: 'Ask a picture, compare models, read text with the local OCR engine, and check a page for UI faults.' },
+ { path: '/ask', changefreq: 'weekly', priority: 0.9, title: 'Ask a picture', text: 'Upload an image or paste a public URL and ask what it shows.' },
+ { path: '/ocr', changefreq: 'weekly', priority: 0.8, title: 'Read text from a picture', text: 'Local Tesseract OCR first, with an optional AI fallback for a signed-in person.' },
+ { path: '/compare', changefreq: 'weekly', priority: 0.8, title: 'Compare models side by side', text: 'The same image through local OCR and through OpenVibe.AI, with the answer, cost and latency side by side.' },
+ { path: '/ui-check', changefreq: 'weekly', priority: 0.9, title: 'Check a page for UI faults', text: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Vision API', text: 'The JSON API, the capabilities other services need, and what leaves this host.' },
+ ];
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config } = 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.vision: the public pages are for search and AI crawlers; sign-in, the API, saved answers and the internal route 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.Vision answers questions about a picture. The cheapest engine first: local Tesseract OCR answers a read question; otherwise OpenVibe.AI\'s routed vision model answers. A compare runs the local reader and AI models on the same image and reports each answer with its model, cost and latency. The UI check flags overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. No face recognition, ever; public AI text never describes people\'s looks.',
+ details: 'POST /api/v1/ask (multipart image, image data URL or image_url) asks a question. POST /api/v1/ocr reads text locally first. POST /api/v1/compare runs the same image through local OCR and up to three AI hints. POST /api/v1/ui-check takes html, url or a screenshot and returns findings. GET /api/v1/benchmarks aggregates compare samples without subjects. A person is the openvibe.vision session or a Network user token; a service token needs vision.ask, vision.ocr, vision.compare or vision.ui.check.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Vision', url: abs('/') },
+ { title: 'Ask a picture', url: abs('/ask') },
+ { title: 'Read text from a picture', url: abs('/ocr') },
+ { title: 'Compare models', url: abs('/compare') },
+ { title: 'The UI check', url: abs('/ui-check') },
+ { title: 'The API', url: abs('/developers') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Benchmark results (JSON)', url: abs('/api/v1/benchmarks') },
+ { title: 'Model hints (JSON)', url: abs('/api/v1/models') },
+ { 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.AI', url: 'https://ai.openvibe.services', note: 'the routing, quotas and provenance every model answer uses' },
+ { title: 'OpenVibe.Design', url: 'https://openvibe.design', note: 'the Arena that calls the UI check' },
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts, tokens and staff roles' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Vision: the home, ask, OCR, compare, the UI check and the API.',
+ base: site,
+ maxBytes: 256 * 1024,
+ sections: [
+ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
+ { title: 'Rules', pages: [
+ { url: '/developers', title: 'No face recognition', text: 'Vision never identifies or recognises faces and never describes a person\'s looks in public AI text.' },
+ { url: '/developers', title: 'No stored images', text: 'Answers store a sha256, the media type and the cleaned text; never the image bytes.' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ 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,210 @@
@@ -0,0 +1,210 @@
+'use strict';
+
+/**
+ * Vision's server-rendered pages. Everything works without JavaScript: every form is a plain POST and every
+ * result is a real page. Public pages are `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in
+ * person is `private, no-store`.
+ *
+ * / the room: ask, compare, OCR and the UI check
+ * /ask upload or URL + a question (POST /ask → /a/:id)
+ * /ocr local OCR (POST /ocr → /a/:id, or inline for a guest)
+ * /compare local OCR vs AI model hints (POST /compare → /a/:id)
+ * /ui-check HTML, URL or screenshot (POST /ui-check → /ui/:id, or inline for a guest)
+ * /a/:id one saved answer (owner or staff)
+ * /ui/:id one saved UI check (owner or staff)
+ * /developers the API
+ * 404
+ */
+const express = require('express');
+const multer = require('multer');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { sameOrigin } = require('./principal');
+const { loadImage } = require('../vision/images');
+const { check: uiCheck } = require('../vision/ui-check');
+const { VisionError } = require('../vision/service');
+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, s, store, vision, ai, ocr, fetchImpl } = ctx;
+ const r = asyncRouter();
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: config.images.maxMb * 1024 * 1024, files: 1 } });
+ r.use(express.urlencoded({ extended: false, limit: '2mb' }));
+ 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 notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty"><h1>That came from somewhere else</h1><p class="vi-muted">Use the form on <a href="/">OpenVibe.Vision</a>.</p></div></div>`) }, 403);
+ const uploadMiddleware = (req, res, next) => upload.single('image')(req, res, (err) => {
+ if (!err) return next();
+ if (err.code === 'LIMIT_FILE_SIZE') return page(req, res, { title: 'That image is too large', body: raw(views.formError(`The image is larger than ${config.images.maxMb} MB.`)) }, 413);
+ return next(err);
+ });
+ const bodyOf = (req) => (req.body && typeof req.body === 'object' ? req.body : {});
+ async function imageOf(req) {
+ return await loadImage({ file: req.file && req.file.buffer, imageUrl: bodyOf(req).image_url, image: bodyOf(req).image, config, fetchImpl });
+ }
+ const viewportOf = (req) => {
+ const b = bodyOf(req);
+ return { w: Math.max(160, Math.min(10000, Number(b.viewport_w) || 1280)), h: Math.max(160, Math.min(10000, Number(b.viewport_h) || 800)) };
+ };
+ const formError = (message, status = 400) => ({ message, status });
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: null,
+ description: 'Ask a picture, compare the local reader with an AI model, and check a page for overflow, cut-off text, low contrast, overlap and layering faults. No face recognition, ever.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
+ }));
+
+ // ── Ask ──────────────────────────────────────────────────
+ r.get('/ask', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'Ask a picture',
+ description: 'Upload an image or paste a public image URL and ask what it shows: describe, read, translate, identify or count.',
+ body: raw(views.ask({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
+ }));
+ r.post('/ask', uploadMiddleware, async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Ask a picture', body: raw(views.signIn({ title: 'Ask a picture', text: 'An answer saved to your account needs an OpenVibe sign-in.', next: '/ask' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ try {
+ const image = await imageOf(req);
+ const out = await vision.ask({
+ requester: `user:${req.viewer.subject}`, subject: req.viewer.subject, image,
+ task: b.task || 'ask', question: b.question || b.q || '', mode: b.mode || 'auto', lang: b.lang, model: b.model,
+ });
+ return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);
+ } catch (err) {
+ return page(req, res, { title: 'Ask a picture', body: raw(views.ask({ signedIn: true, ai: ai.enabled, problem: err instanceof VisionError ? err.detail : 'That request could not be answered.', values: { question: b.question || b.q || '', task: b.task || 'ask', mode: b.mode || 'auto', image_url: b.image_url || '' } })) }, err instanceof VisionError ? err.status : 500);
+ }
+ });
+
+ // ── OCR ──────────────────────────────────────────────────
+ r.get('/ocr', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'Read text from a picture',
+ description: 'Local Tesseract OCR first: read a receipt, a sign or a screenshot on this host, with no model call.',
+ body: raw(views.ocr({ signedIn: Boolean(signedIn(req)), ai: ai.enabled })),
+ }));
+ r.post('/ocr', uploadMiddleware, async (req, res) => {
+ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ const fallback = b.fallback === '1' || b.fallback === 'true' || b.fallback === 'on';
+ if (!signedIn(req) && fallback) return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: false, ai: ai.enabled, problem: 'The AI fallback needs a signed-in account. Local OCR runs without one.' })) }, 401);
+ try {
+ const image = await imageOf(req);
+ const out = await vision.ocr({
+ requester: signedIn(req) ? `user:${req.viewer.subject}` : 'anonymous', subject: signedIn(req) ? req.viewer.subject : null,
+ image, lang: b.lang, fallback: signedIn(req) && fallback, model: b.model,
+ });
+ if (signedIn(req)) return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);
+ return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: false, ai: ai.enabled, result: out })) });
+ } catch (err) {
+ return page(req, res, { title: 'Read text from a picture', body: raw(views.ocr({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, problem: err instanceof VisionError ? err.detail : 'That image could not be read.' })) }, err instanceof VisionError ? err.status : 500);
+ }
+ });
+
+ // ── Compare ──────────────────────────────────────────────
+ r.get('/compare', async (req, res) => {
+ let models = [];
+ try { models = ai.enabled ? await ai.offeredModels() : []; } catch { models = []; }
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'Compare models side by side',
+ description: 'Run the same image through the local OCR reader and through OpenVibe.AI, and compare the answer, cost and latency side by side.',
+ body: raw(views.compare({ signedIn: Boolean(signedIn(req)), ai: ai.enabled, models, configured: config.ai.models })),
+ });
+ });
+ r.post('/compare', uploadMiddleware, async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Compare models', body: raw(views.signIn({ title: 'Compare models', text: 'A compare spends an AI run, so it needs an OpenVibe account.', next: '/compare' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ const models = b.models == null ? [] : (Array.isArray(b.models) ? b.models : [b.models]);
+ try {
+ const image = await imageOf(req);
+ const out = await vision.compare({
+ requester: `user:${req.viewer.subject}`, subject: req.viewer.subject, image,
+ task: b.task || 'ask', question: b.question || b.q || '', models, lang: b.lang,
+ });
+ return res.redirect(303, `/a/${encodeURIComponent(out.id)}`);
+ } catch (err) {
+ return page(req, res, { title: 'Compare models', body: raw(views.compare({ signedIn: true, ai: ai.enabled, models: [], configured: config.ai.models, problem: err instanceof VisionError ? err.detail : 'That compare could not run.', values: { question: b.question || '', task: b.task || 'ask', image_url: b.image_url || '' } })) }, err instanceof VisionError ? err.status : 500);
+ }
+ });
+
+ // ── UI check ─────────────────────────────────────────────
+ r.get('/ui-check', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'Check a page for UI faults',
+ description: 'Overflow, cut-off text, low contrast, overlap and layering faults from HTML, a URL or a screenshot. The API Design\'s Arena calls.',
+ body: raw(views.uiCheck({ signedIn: Boolean(signedIn(req)) })),
+ }));
+ r.post('/ui-check', uploadMiddleware, async (req, res) => {
+ if (signedIn(req) && !sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const b = bodyOf(req);
+ const viewport = viewportOf(req);
+ try {
+ let out; let image = null; let source = 'html';
+ if (req.file || b.image || b.image_url) {
+ image = await imageOf(req); source = 'screenshot';
+ out = await uiCheck({ imageBuffer: image.buffer, viewport, ocr, maxFindings: config.ui.maxFindings });
+ } else if (b.html) {
+ if (Buffer.byteLength(String(b.html), 'utf8') > config.ui.maxHtmlBytes) throw new VisionError(413, 'vision.ui_too_large', 'that HTML is larger than the UI check limit');
+ out = await uiCheck({ html: String(b.html), viewport, maxFindings: config.ui.maxFindings });
+ } else if (b.url) {
+ source = 'url';
+ out = await uiCheck({ url: String(b.url), viewport, config, fetchImpl, maxFindings: config.ui.maxFindings });
+ } else {
+ throw new VisionError(400, 'vision.invalid', 'send HTML, a URL or a screenshot');
+ }
+ const saved = signedIn(req) ? await store.saveUiCheck({
+ subject: req.viewer.subject, requester: `user:${req.viewer.subject}`, source: out.source,
+ url: out.url || null, imageSha256: image ? image.sha256 : null, viewport: out.viewport,
+ engine: out.engine, findings: out.findings, summary: out,
+ }) : null;
+ if (saved) return res.redirect(303, `/ui/${encodeURIComponent(saved.id)}`);
+ return page(req, res, { title: 'Check a page for UI faults', body: raw(views.uiCheck({ signedIn: false, result: out })) });
+ } catch (err) {
+ return page(req, res, { title: 'Check a page for UI faults', body: raw(views.uiCheck({ signedIn: Boolean(signedIn(req)), problem: err instanceof VisionError ? err.detail : (err && err.code ? err.message : 'That page could not be checked.'), values: { html: b.html || '', url: b.url || '', viewport_w: viewport.w, viewport_h: viewport.h } })) }, err instanceof VisionError ? err.status : 400);
+ }
+ });
+
+ // ── One saved answer / UI check ──────────────────────────
+ r.get('/a/:id', async (req, res) => {
+ const row = await store.answerById(String(req.params.id || ''));
+ if (!row) return notFound(req, res);
+ const owner = await store.answerSubject(row.id);
+ const mine = Boolean(signedIn(req) && owner && req.viewer.subject === owner);
+ if (!mine && !staffViewer(req)) return notFound(req, res);
+ return page(req, res, { title: row.task === 'ocr' ? 'An OCR answer' : 'An answer', body: raw(views.answer({ a: row, mine })), cache: null });
+ });
+ r.get('/ui/:id', async (req, res) => {
+ const row = await store.uiCheckById(String(req.params.id || ''));
+ if (!row) return notFound(req, res);
+ const owner = await store.uiCheckSubject(row.id);
+ const mine = Boolean(signedIn(req) && owner && req.viewer.subject === owner);
+ if (!mine && !staffViewer(req)) return notFound(req, res);
+ return page(req, res, { title: 'A UI check', body: raw(views.uiCheckResult({ c: row, mine })), cache: null });
+ });
+
+ // ── Developers ───────────────────────────────────────────
+ r.get('/developers', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Vision API',
+ description: 'Ask, OCR, compare and the UI check as JSON. Design\'s Arena calls POST /api/v1/ui-check with vision.ui.check.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Vision API', url: `${base()}/developers`, description: 'Ask a picture, OCR, compare and the UI check as JSON.', siteUrl: base() })],
+ body: raw(views.developers({ ai: ai.enabled, models: config.ai.models })),
+ }));
+
+ 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,127 @@
@@ -0,0 +1,127 @@
+'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 (vision_at).
+ * { kind: 'service', sub: 'svc:design', cap: [...], project, claims }
+ * a Network service, app or agent token for audience openvibe.vision. Each machine route names exactly one
+ * capability (vision.ui.check for Design's Arena, vision.ask, vision.ocr, vision.compare) and checks the
+ * token's grant list with openvibe-contracts capabilities.grants. Unknown capability ids are accepted here
+ * until the main session registers the manifests; the main session adds the grants.
+ * { 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 spend a signed-in visitor's Vision calls.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/** Vision's four machine capabilities (the main session registers them; v1 accepts the grant directly). */
+const CAPABILITIES = Object.freeze(['vision.ask', 'vision.ocr', 'vision.compare', 'vision.ui.check']);
+
+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: 'service', sub: r.claims.sub, requester: requesterOfSub(r.claims.sub),
+ cap: Array.isArray(r.claims.cap) ? r.claims.cap : [], 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();
+ }
+
+ const granted = (p, cap) => p && p.kind === 'service' && capabilities.grants(p.cap, cap);
+
+ /**
+ * One route, one capability. `allowUser` also admits a signed-in person (Vision's own pages and the person's
+ * calls); `allowAnonymous` admits the local-only routes (OCR and the UI check) so the public demos work.
+ */
+ function requireCapability(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Vision's capabilities`);
+ return function guard(req, res, next) {
+ const p = req.principal;
+ if (!p || p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at openvibe.vision, or send a Network token holding ${cap}.`, ctx: req.ov });
+ if (p.kind === 'user') {
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'vision.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.vision itself.', ctx: req.ov });
+ return next();
+ }
+ return granted(p, cap) ? next() : http.sendProblem(res, 403, 'capability.denied', { detail: `${cap} not granted`, ctx: req.ov });
+ };
+ }
+
+ /** A person or a service token holding cap; anonymous only when allowAnonymous is set. */
+ function optional(cap, { allowAnonymous = false } = {}) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`optional(${cap}): not one of OpenVibe.Vision's capabilities`);
+ return function guard(req, res, next) {
+ const p = req.principal;
+ if (p && p.kind === 'user') {
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'vision.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.vision itself.', ctx: req.ov });
+ return next();
+ }
+ if (p && p.kind === 'service') return granted(p, cap) ? next() : http.sendProblem(res, 403, 'capability.denied', { detail: `${cap} not granted`, ctx: req.ov });
+ if (allowAnonymous) return next();
+ return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at openvibe.vision, or send a Network token holding ${cap}.`, ctx: req.ov });
+ };
+ }
+
+ return { middleware, resolve, requireCapability, optional, granted, CAPABILITIES };
+}
+
+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,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+
+/**
+ * Account export and deletion → Vision (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
+ * answers, their compare samples and their UI checks. The local OCR text is part of the answer; image bytes are
+ * never stored, so there is nothing else to erase. A row written by a service token has no subject and is not a
+ * person's account data.
+ *
+ * The database stores bare usr_ ids in the subject columns; an exported file never includes another person's row.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ // Compare samples before their answer, so the deletion counts them itself rather than through the FK cascade.
+ { table: 'vision_compare_samples', subject: 'subject', value: (usr) => usr, file: 'compare.json', erase: 'delete' },
+ { table: 'vision_answers', subject: 'subject', value: (usr) => usr, file: 'answers.json', erase: 'delete' },
+ { table: 'vision_ui_checks', subject: 'subject', value: (usr) => usr, file: 'ui-checks.json', erase: 'delete' },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'vision',
+ tables: TABLES,
+ note: 'Image bytes are never stored: an answer holds its cleaned text, a sha256 of the image and model provenance.',
+ 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.Vision: 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.Vision — process entry. `node server/index.js`
+ * Listens on PORT (5130) 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, telemetry closes,
+ * 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.Vision', 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.Vision] ${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.Vision] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing
+ * key, the OAuth client, OpenVibe.AI and the local OCR binary are optional and say so. Vision serves local OCR
+ * and the UI check without them; ask and compare need AI only when they cannot answer locally.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null, ai = null, ocr = null }) {
+ return createReadiness({
+ service: 'vision',
+ 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 and AI calls cannot complete') },
+ { name: 'ai', required: false, check: () => (ai && ai.enabled ? 'AI is configured on demand; a run is only made when a local engine cannot answer' : 'OpenVibe.AI is not configured: local OCR and the UI check still serve') },
+ {
+ name: 'local_ocr', required: false,
+ check: async () => {
+ if (!ocr) return 'no local OCR engine';
+ const v = await ocr.available();
+ return v ? { ok: true, detail: v } : 'tesseract is not installed: OCR and the screenshot pass answer 503';
+ },
+ },
+ ],
+ });
+}
+
+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,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * This is Today's layout with Vision'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.Vision';
+const TAGLINE = 'See with the best model for the job.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Ask a picture', href: '/ask' },
+ { label: 'OCR', href: '/ocr' },
+ { label: 'Compare', href: '/compare' },
+ { label: 'UI check', href: '/ui-check' },
+ { 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: 'vision',
+ 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: 'vision', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'vision' })}
+<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/vision.css')}">
+<script src="${ovServe.url('motion.js')}" defer></script>
+<script src="${asset('js/vision.js')}" defer></script>
+${render(scripts)}
+<meta name="ov-boost" content="vision@${require('./html').esc(RELEASE)}">
+<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
+<main id="main" class="vi ov-room" style="${rooms.style('vision')}">
+${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,189 @@
@@ -0,0 +1,189 @@
+'use strict';
+
+/**
+ * Vision's rows: one answer (ask/describe/OCR/compare), the compare samples that answer produced, and one UI
+ * check. Image bytes are never here. A row with a subject is the person's and is exported/erased with their
+ * account; a row from a service token has no subject.
+ *
+ * GET /api/v1/benchmarks aggregates these rows without subjects: per provider/model, task, samples, successful
+ * answers, mean latency and mean cost. v1 keeps the measurements; the public Arena leaderboards are v2.
+ */
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
+
+function wireAnswer(row) {
+ if (!row) return null;
+ let answer = row.answer;
+ if (typeof answer === 'string') { try { answer = JSON.parse(answer); } catch { answer = {}; } }
+ return {
+ id: row.id,
+ task: row.task,
+ question: row.question == null ? null : row.question,
+ mode: row.mode,
+ source: row.source,
+ source_url: row.source_url == null ? null : row.source_url,
+ image_sha256: row.image_sha256,
+ image_mime: row.image_mime,
+ image_bytes: Number(row.image_bytes) || 0,
+ text: row.text == null ? null : row.text,
+ answer: answer && typeof answer === 'object' ? answer : {},
+ model: row.model == null ? null : row.model,
+ provider: row.provider == null ? null : row.provider,
+ route: row.route == null ? null : row.route,
+ reason: row.reason == null ? null : row.reason,
+ cost_usd: num(row.cost_usd),
+ latency_ms: Number(row.latency_ms) || 0,
+ created_at: iso(row.created_at),
+ };
+}
+
+function wireSample(row) {
+ if (!row) return null;
+ return {
+ engine: row.engine,
+ requested_model: row.requested_model == null ? null : row.requested_model,
+ model: row.model == null ? null : row.model,
+ provider: row.provider == null ? null : row.provider,
+ route: row.route == null ? null : row.route,
+ ok: Boolean(row.ok),
+ text: row.text == null ? null : row.text,
+ cost_usd: num(row.cost_usd),
+ latency_ms: Number(row.latency_ms) || 0,
+ error_code: row.error_code == null ? null : row.error_code,
+ };
+}
+
+function wireUiCheck(row) {
+ if (!row) return null;
+ let findings = row.findings; let summary = row.summary;
+ if (typeof findings === 'string') { try { findings = JSON.parse(findings); } catch { findings = []; } }
+ if (typeof summary === 'string') { try { summary = JSON.parse(summary); } catch { summary = {}; } }
+ return {
+ id: row.id,
+ source: row.source,
+ url: row.url == null ? null : row.url,
+ image_sha256: row.image_sha256 == null ? null : row.image_sha256,
+ viewport: { w: Number(row.viewport_w) || 1280, h: Number(row.viewport_h) || 800 },
+ engine: row.engine,
+ findings: Array.isArray(findings) ? findings : [],
+ summary: summary && typeof summary === 'object' ? summary : {},
+ created_at: iso(row.created_at),
+ };
+}
+
+function createStore(s) {
+ const db = s.db;
+
+ async function saveAnswer({ subject = null, requester, task, question = null, mode = 'auto', image, text = null, answer = {}, model = null, provider = null, route = null, reason = null, costUsd = 0, latencyMs = 0 }) {
+ const id = s.newId('vis');
+ const row = await db.maybe(
+ `INSERT INTO vision_answers (id, subject, requester, task, question, mode, source, source_url, image_sha256, image_mime, image_bytes,
+ text, answer, model, provider, route, reason, cost_usd, latency_ms, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, $17, $18, $19, now()) RETURNING *`,
+ [id, subject, requester, task, question, mode, image.source, image.sourceUrl || null, image.sha256, image.mime, image.bytes,
+ text == null ? null : String(text).slice(0, 20000), JSON.stringify(answer || {}), model, provider, route, reason, Math.max(0, Number(costUsd) || 0), Math.max(0, Math.round(Number(latencyMs) || 0))],
+ );
+ return wireAnswer(row);
+ }
+
+ async function answerById(id) {
+ return wireAnswer(await db.maybe('SELECT * FROM vision_answers WHERE id = $1', [String(id || '')]));
+ }
+
+ /** Ownership is checked from the row, never from a value the API returns. */
+ async function answerSubject(id) {
+ const row = await db.maybe('SELECT subject FROM vision_answers WHERE id = $1', [String(id || '')]);
+ return row ? row.subject : null;
+ }
+
+ async function listAnswers({ subject = null, requester = null, limit = 20 } = {}) {
+ const n = Math.min(50, Math.max(1, Number(limit) || 20));
+ const args = []; const where = [];
+ if (subject) { args.push(subject); where.push(`subject = $${args.length}`); }
+ if (requester) { args.push(requester); where.push(`requester = $${args.length}`); }
+ args.push(n);
+ const rows = await db.many(`SELECT * FROM vision_answers ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY created_at DESC, id DESC LIMIT $${args.length}`, args);
+ return rows.map(wireAnswer);
+ }
+
+ async function saveCompare({ subject = null, requester, task = 'ask', image, question = null, samples = [] }) {
+ return await s.tx(async (t) => {
+ const answerId = s.newId('vis');
+ const rows = (Array.isArray(samples) ? samples : []).slice(0, 8).map((x) => ({
+ engine: x.engine === 'local' ? 'local' : 'ai',
+ requested_model: x.requestedModel == null ? null : String(x.requestedModel).slice(0, 120),
+ model: x.model == null ? null : String(x.model).slice(0, 120),
+ provider: x.provider == null ? null : String(x.provider).slice(0, 80),
+ route: x.route == null ? null : String(x.route).slice(0, 80),
+ ok: Boolean(x.ok),
+ text: x.text == null ? null : String(x.text).slice(0, 20000),
+ costUsd: Math.max(0, Number(x.costUsd) || 0),
+ latencyMs: Math.max(0, Math.round(Number(x.ms) || 0)),
+ errorCode: x.errorCode == null ? null : String(x.errorCode).slice(0, 80),
+ }));
+ const best = rows.filter((x) => x.ok).sort((a, b) => a.costUsd - b.costUsd || a.latencyMs - b.latencyMs)[0] || null;
+ const text = best ? best.text : null;
+ const answerRow = await t.maybe(
+ `INSERT INTO vision_answers (id, subject, requester, task, question, mode, source, source_url, image_sha256, image_mime, image_bytes,
+ text, answer, model, provider, route, reason, cost_usd, latency_ms, created_at)
+ VALUES ($1, $2, $3, $4, $5, 'auto', $6, $7, $8, $9, $10, $11, $12::jsonb, $13, $14, $15, $16, $17, $18, now()) RETURNING *`,
+ [answerId, subject, requester, task, question, image.source, image.sourceUrl || null, image.sha256, image.mime, image.bytes,
+ text == null ? null : String(text).slice(0, 20000), JSON.stringify({ samples: rows }), best && best.model, best && best.provider, best && best.route,
+ best ? 'cheapest successful engine in this compare' : 'no engine answered', best ? best.costUsd : 0, best ? best.latencyMs : 0],
+ );
+ for (const x of rows) {
+ await t.exec(
+ `INSERT INTO vision_compare_samples (id, answer_id, subject, task, engine, requested_model, model, provider, route, ok, text, cost_usd, latency_ms, error_code, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, now())`,
+ [s.newId('vcm'), answerId, subject, ['ask', 'describe', 'ocr', 'translate', 'identify', 'count'].includes(task) ? task : 'ask',
+ x.engine, x.requested_model, x.model, x.provider, x.route, x.ok, x.text, x.costUsd, x.latencyMs, x.errorCode],
+ );
+ }
+ return { answer: wireAnswer(answerRow), samples: rows.map((x) => ({ engine: x.engine, requested_model: x.requested_model, model: x.model, provider: x.provider, route: x.route, ok: x.ok, text: x.text, cost_usd: x.costUsd, latency_ms: x.latencyMs, error_code: x.errorCode })) };
+ });
+ }
+
+ async function saveUiCheck({ subject = null, requester, source, url = null, imageSha256 = null, viewport, engine, findings, summary }) {
+ const id = s.newId('vuc');
+ const row = await db.maybe(
+ `INSERT INTO vision_ui_checks (id, subject, requester, source, url, image_sha256, viewport_w, viewport_h, engine, findings, summary, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, now()) RETURNING *`,
+ [id, subject, requester, source, url, imageSha256, Math.max(160, Number(viewport && viewport.w) || 1280), Math.max(160, Number(viewport && viewport.h) || 800), engine, JSON.stringify(findings || []), JSON.stringify(summary || {})],
+ );
+ return wireUiCheck(row);
+ }
+
+ async function uiCheckById(id) {
+ return wireUiCheck(await db.maybe('SELECT * FROM vision_ui_checks WHERE id = $1', [String(id || '')]));
+ }
+
+ async function uiCheckSubject(id) {
+ const row = await db.maybe('SELECT subject FROM vision_ui_checks WHERE id = $1', [String(id || '')]);
+ return row ? row.subject : null;
+ }
+
+ /** Aggregate only: no subject, no image, no answer text. Enough for the v1 first proof and the v2 Arena. */
+ async function benchmarks({ task = null, limit = 50 } = {}) {
+ const n = Math.min(100, Math.max(1, Number(limit) || 50));
+ const args = []; let where = "ok = true AND model IS NOT NULL";
+ if (task) { args.push(String(task)); where += ` AND task = $${args.length}`; }
+ args.push(n);
+ const rows = await db.many(
+ `SELECT task, engine, provider, model, COUNT(*)::int AS samples,
+ ROUND(AVG(latency_ms))::int AS avg_latency_ms, ROUND(AVG(cost_usd)::numeric, 6) AS avg_cost_usd,
+ ROUND(MIN(cost_usd)::numeric, 6) AS min_cost_usd, ROUND(MAX(latency_ms))::int AS max_latency_ms
+ FROM vision_compare_samples WHERE ${where}
+ GROUP BY task, engine, provider, model ORDER BY avg_cost_usd ASC, avg_latency_ms ASC LIMIT $${args.length}`,
+ args,
+ );
+ return rows.map((r) => ({
+ task: r.task, engine: r.engine, provider: r.provider, model: r.model, samples: Number(r.samples) || 0,
+ avg_latency_ms: Number(r.avg_latency_ms) || 0, avg_cost_usd: num(r.avg_cost_usd),
+ min_cost_usd: num(r.min_cost_usd), max_latency_ms: Number(r.max_latency_ms) || 0,
+ }));
+ }
+
+ return { saveAnswer, answerById, answerSubject, listAnswers, saveCompare, saveUiCheck, uiCheckById, uiCheckSubject, benchmarks };
+}
+
+module.exports = { createStore, wireAnswer, wireSample, wireUiCheck };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and MediaHub):
+ * an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. A summary that
+ * names looks at all is not repaired word by word — it is replaced by the entry's title, as Meme's plan says.
+ * Verbatim quotes are never passed through this: rewriting a quote would falsify the citation.
+ */
+const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');
+
+/** The summary to store for an AI-written entry summary. */
+function cleanSummary(title, summary) {
+ const t = String(title || '').trim() || 'Untitled item';
+ 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 };
+
+/**
+ * Public AI text in Vision: an answer that names a person's looks at all is replaced by a neutral sentence,
+ * never repaired word by word. The prompt already forbids it; this is the guarantee after the model speaks.
+ */
+const SAFE_ANSWER = 'I can describe the picture without describing anyone\'s appearance. Ask about the scene, the text, the objects or what is happening.';
+
+function cleanAnswer(text, { fallback = SAFE_ANSWER } = {}) {
+ const s = String(text == null ? '' : text).trim();
+ if (!s) return fallback;
+ // Live's rule: a sentence about someone's looks goes whole (noLooks can leave "has long blonde hair" behind
+ // once it has neutralised the pronoun); the rest still passes noLooks. If nothing look-free remains, the
+ // neutral sentence is the answer.
+ const kept = s.split(/(?<=[.!?])\s+/).filter((sentence) => !hasLooks(sentence)).join(' ').trim();
+ if (!kept) return fallback;
+ const cleaned = String(noLooks(kept) || '').trim();
+ return cleaned && !hasLooks(cleaned) ? cleaned : fallback;
+}
+
+module.exports = { cleanSummary, cleanAnswer, noLooks, hasLooks, SAFE_ANSWER };
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -0,0 +1,261 @@
+'use strict';
+
+/**
+ * The Vision room's pages (plan 2026-10-28 §5.8). Server-rendered and whole without JavaScript: every action is
+ * a plain form or link. public/css/vision.css and public/js/vision.js are one-comment placeholders the main
+ * session replaces; the forms and results work without them. Ids and classes start with `vi-`.
+ */
+const { esc, time } = require('../render/html');
+const sc = require('openvibe-shared/showcase');
+
+const MODEL_LABEL = (a) => (a && a.model ? `${a.provider ? `${a.provider}:` : ''}${a.model}` : 'the local engine');
+
+function motif() {
+ return `<div class="vi-race" aria-hidden="true">
+<svg class="vi-race-img" viewBox="0 0 420 260" preserveAspectRatio="none"><rect x="1" y="1" width="418" height="258" rx="18" fill="none" stroke="currentColor" stroke-opacity=".25"/><path d="M70 190 L150 120 L230 150 L350 70" fill="none" stroke="currentColor" stroke-width="3"/><circle cx="70" cy="190" r="6" fill="currentColor"/><circle cx="150" cy="120" r="6" fill="currentColor"/><circle cx="230" cy="150" r="6" fill="currentColor"/><circle cx="350" cy="70" r="6" fill="currentColor"/></svg>
+<span class="vi-race-box vi-race-a"><b>local OCR</b><small>0.0000 USD · 41 ms</small></span>
+<span class="vi-race-box vi-race-b"><b>vision model</b><small>0.0007 USD · 1.2 s</small></span>
+<span class="vi-race-tag">same image, two boxes</span>
+</div>`;
+}
+
+function home({ signedIn = false, ai = false } = {}) {
+ const tryIt = `<section class="vi-section vi-try" id="vi-try" aria-labelledby="vi-try-h">
+<div class="vi-head-row"><h2 id="vi-try-h">Ask a picture</h2><span class="vi-count">${ai ? 'local OCR first, then OpenVibe.AI' : 'local engines only on this host right now'}</span></div>
+<p class="vi-lede">Describe, read, translate, identify or count. Upload a file or paste a public image URL; the local reader answers a read question before any model is called.</p>
+<div class="vi-actions"><a class="vi-btn" href="/ask">Ask a picture</a><a class="vi-btn vi-btn-quiet" href="/ocr">Read text only</a><a class="vi-btn vi-btn-quiet" href="/compare">Compare models</a><a class="vi-btn vi-btn-quiet" href="/ui-check">Check a page</a></div>
+${signedIn ? '' : '<p class="vi-fine">Ask and compare save an answer to your account, so they need <a href="/auth/login?next=%2Fask">an OpenVibe sign-in</a>. OCR and the UI check work without one.</p>'}
+</section>`;
+ const room = sc.room({
+ room: 'vision',
+ hero: {
+ title: 'See with the best model for the job',
+ lede: 'Ask a picture. Read its text with a local OCR engine first. Compare the local reader with a routed vision model on the same image, with the cost and the time beside each answer. Check a page for the overflow, low contrast and layering faults a person would catch. No face recognition, ever.',
+ start: { href: '/ask', label: 'Ask a picture' },
+ chips: [{ label: 'Read text', href: '/ocr' }, { label: 'Compare models', href: '/compare' }, { label: 'UI check', href: '/ui-check' }, { label: 'The API', href: '/developers' }],
+ proof: [{ value: '0.0000', label: 'USD for the local OCR first pass' }],
+ motif: motif(),
+ },
+ scenes: [
+ {
+ title: 'A receipt, read for nothing',
+ text: 'The local reader (Tesseract, on this host) reads the words a receipt actually needs: merchant, date, lines, total. No image leaves the machine and no model is called. Only when the local pass finds no usable text does a signed-in person\'s AI fallback run, and the answer says so.',
+ html: `<div class="vi-demo-receipt"><span class="vi-demo-kicker">LOCAL OCR</span><b>OPENVIBE MARKET</b><span>2026-10-28 · 14:02</span><span>Oat milk 3.40</span><span>Bread 2.10</span><strong>TOTAL 5.50</strong><small>confidence 91.4 · 0.0000 USD · 38 ms</small></div>`,
+ action: { href: '/ocr', label: 'Read a picture' },
+ },
+ {
+ title: 'The same image, side by side',
+ text: 'A compare sends one image to the local reader and to OpenVibe.AI. AI\'s router is the authority: the provider and model shown are the ones that answered, never the hint. Cost comes from AI\'s own run, latency is measured here.',
+ html: `<table class="vi-demo-table"><caption>one image, three answers</caption><thead><tr><th scope="col">engine</th><th scope="col">answer</th><th scope="col">cost</th><th scope="col">time</th></tr></thead><tbody><tr><td>local OCR</td><td>“TOTAL 5.50”</td><td>0.0000</td><td>38 ms</td></tr><tr><td>pool:fast</td><td>a receipt from a market</td><td>0.0004</td><td>1.1 s</td></tr><tr><td>pool:best</td><td>the total is 5.50</td><td>0.0012</td><td>2.4 s</td></tr></tbody></table>`,
+ action: { href: '/compare', label: 'Run a compare' },
+ },
+ {
+ title: 'The UI check catches the overflow',
+ text: 'Drop in HTML, a page URL or a screenshot. The static pass reads the boxes and the colours: a container wider than the viewport, nowrap text clipped by its box, body text under 4.5:1, positioned boxes on top of each other, a z-index with no effect. Arena calls the same API after it generates a page.',
+ html: `<div class="vi-demo-check"><div class="vi-demo-page"><span class="vi-demo-overflow">this card is 1400px wide</span><span class="vi-demo-clip">a headline that gets cut off at the edge</span></div><ul><li class="is-bad"><b>overflow</b><span>1400px in a 390px view</span></li><li class="is-bad"><b>cut off</b><span>nowrap inside 120px</span></li><li class="is-bad"><b>contrast</b><span>2.4:1, needs 4.5:1</span></li></ul></div>`,
+ action: { href: '/ui-check', label: 'Check a page' },
+ },
+ ],
+ faq: { items: [
+ ['Does Vision recognise faces?', 'No. Never. Vision identifies what an image shows without identifying people, and public AI text never describes a person\'s looks. The prompt and the stored answer both enforce the no-looks rule.'],
+ ['Which model answers?', 'Every answer names the model and provider that actually answered and the reason it was chosen. Local OCR is first for a read question; otherwise OpenVibe.AI routes the run and its provenance is what Vision reports.'],
+ ['Are my images stored?', 'No image bytes are stored. An answer keeps a sha256, the media type, the cleaned text and the model provenance. A signed-in person\'s rows are exported and erased with their account.'],
+ ] },
+ });
+ const at = room.indexOf('<section class="sc-room-scenes"');
+ return `<div class="vi-wrap vi-home" id="vi-home">${at < 0 ? room : room.slice(0, at) + tryIt + room.slice(at)}</div>`;
+}
+
+function signIn({ title, text, next }) {
+ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-sign-in"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="vi-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
+}
+
+function formError(message) {
+ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-form-error"><h1>That did not go through</h1><p>${esc(message)}</p><a class="vi-btn" href="/">Back to Vision</a></div></div>`;
+}
+
+function imageFields({ imageUrl = '', values = {} } = {}) {
+ return `<div class="vi-field-grid">
+<label class="vi-field"><span>Upload an image</span><input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/avif"></label>
+<label class="vi-field"><span>…or a public image URL</span><input type="url" name="image_url" value="${esc(imageUrl || values.image_url || '')}" placeholder="https://openvibe.media/o/med_…" maxlength="2048"></label>
+</div>`;
+}
+
+function ask({ signedIn = false, ai = false, problem = null, values = {} } = {}) {
+ const v = { task: 'ask', mode: 'auto', ...values };
+ return `<div class="vi-wrap" id="vi-ask">
+<header class="vi-page-h"><p class="vi-kicker">Ask a picture</p><h1>What does the image show?</h1>
+<p class="vi-lede">Describe, read, translate, identify or count. A read question goes to the local OCR reader first; the answer says which engine answered and why. No face recognition, ever.</p></header>
+${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+${signedIn ? '' : `<p class="vi-note">Ask saves the answer to an account. <a href="/auth/login?next=%2Fask">Sign in with OpenVibe</a> to ask; <a href="/ocr">OCR</a> and the <a href="/ui-check">UI check</a> work without one.</p>`}
+${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured on this host. A read question still runs on the local OCR engine.</p>'}
+<form class="vi-form vi-card" id="vi-ask-form" method="post" action="/ask" enctype="multipart/form-data">
+${imageFields({ values: v })}
+<div class="vi-field-grid">
+<label class="vi-field"><span>Task</span><select name="task">
+${[['ask', 'Ask a question'], ['describe', 'Describe the image'], ['identify', 'Identify what it is'], ['count', 'Count something'], ['translate', 'Read and translate'], ['ocr', 'Read the text']].map(([k, label]) => `<option value="${k}"${v.task === k ? ' selected' : ''}>${label}</option>`).join('')}
+</select></label>
+<label class="vi-field"><span>Mode</span><select name="mode">
+${[['auto', 'Auto: cheapest engine first'], ['local', 'Local only: no model call'], ['ai', 'AI directly']].map(([k, label]) => `<option value="${k}"${v.mode === k ? ' selected' : ''}>${label}</option>`).join('')}
+</select></label>
+</div>
+<label class="vi-field"><span>Question</span><input name="question" type="text" maxlength="2000" value="${esc(v.question || '')}" placeholder="What does the sign say? How many bottles are there?"></label>
+<div class="vi-actions"><button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Ask</button><a class="vi-btn vi-btn-quiet" href="/ocr">Just read the text</a></div>
+${signedIn ? '' : '<p class="vi-fine">The Ask button needs a signed-in account; the other pages do not.</p>'}
+</form>
+</div>`;
+}
+
+function ocr({ signedIn = false, ai = false, problem = null, result = null } = {}) {
+ return `<div class="vi-wrap" id="vi-ocr">
+<header class="vi-page-h"><p class="vi-kicker">Local first</p><h1>Read text from a picture</h1>
+<p class="vi-lede">Tesseract 5 runs on this host. Nothing is sent to a model unless you are signed in, tick the fallback, and the local pass finds no usable text. The reading is stored as text; the image is not.</p></header>
+${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+<div class="vi-two-col">
+<form class="vi-form vi-card" id="vi-ocr-form" method="post" action="/ocr" enctype="multipart/form-data">
+${imageFields()}
+<label class="vi-field"><span>Language</span><input name="lang" type="text" value="eng" maxlength="15" pattern="[a-z]{3}(\\+[a-z]{3}){0,3}"></label>
+<label class="vi-check"><input type="checkbox" name="fallback" value="1"><span>If the local reader finds no text, ask OpenVibe.AI${signedIn ? '' : ' (needs a sign-in)'}</span></label>
+<div class="vi-actions"><button class="vi-btn" type="submit">Read locally</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask a question instead</a></div>
+${ai ? '' : '<p class="vi-fine">AI fallback is off on this host.</p>'}
+</form>
+${result ? `<aside class="vi-card vi-result" id="vi-ocr-result"><h2>Reading</h2><pre class="vi-ocr-text">${esc(result.text || 'No text was found.')}</pre><dl class="vi-facts"><div><dt>Engine</dt><dd>${esc(result.model || result.engine || 'local')}</dd></div><div><dt>Confidence</dt><dd>${result.confidence == null ? '—' : esc(result.confidence)}</dd></div><div><dt>Time</dt><dd>${Number(result.latency_ms) || 0} ms</dd></div><div><dt>Cost</dt><dd>${esc(Number(result.cost_usd) || 0)} USD</dd></div></dl><p class="vi-fine">${esc(result.reason || 'The local reader answered first.')}</p></aside>` : '<aside class="vi-card vi-result vi-muted" id="vi-ocr-empty"><h2>No image yet</h2><p>Upload a PNG, JPEG, WebP, GIF, BMP, TIFF or AVIF and press Read locally. The words come back in reading order.</p></aside>'}
+</div>
+</div>`;
+}
+
+function compare({ signedIn = false, ai = false, models = [], configured = [], problem = null, values = {} } = {}) {
+ const v = { task: 'ask', ...values };
+ const options = [];
+ const seen = new Set();
+ for (const m of [...models.map((x) => (x.provider && x.provider !== 'ai' ? `${x.provider}:${x.model || ''}` : (x.model || 'openvibe'))), ...configured]) {
+ if (m && !seen.has(m)) { seen.add(m); options.push(m); }
+ }
+ if (!options.length) options.push('openvibe');
+ return `<div class="vi-wrap" id="vi-compare">
+<header class="vi-page-h"><p class="vi-kicker">Side by side</p><h1>Compare models on one image</h1>
+<p class="vi-lede">The local OCR reader runs first and is free. Each AI hint is one OpenVibe.AI run; the provider and model that answer are the ones AI reports, never the hint. Cost and latency are measured per engine.</p></header>
+${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+${signedIn ? '' : '<p class="vi-note">A compare spends an AI run, so it needs <a href="/auth/login?next=%2Fcompare">an OpenVibe sign-in</a>.</p>'}
+${ai ? '' : '<p class="vi-note is-warm">OpenVibe.AI is not configured: a compare would show the local reader only.</p>'}
+<form class="vi-form vi-card" id="vi-compare-form" method="post" action="/compare" enctype="multipart/form-data">
+${imageFields({ values: v })}
+<label class="vi-field"><span>Question</span><input name="question" type="text" maxlength="2000" value="${esc(v.question || '')}" placeholder="Read and describe this image."></label>
+<label class="vi-field"><span>Task</span><select name="task">${[['ask', 'Ask'], ['describe', 'Describe'], ['ocr', 'Read'], ['count', 'Count'], ['identify', 'Identify'], ['translate', 'Translate']].map(([k, label]) => `<option value="${k}"${v.task === k ? ' selected' : ''}>${label}</option>`).join('')}</select></label>
+<fieldset class="vi-models"><legend>AI model hints <span class="vi-fine">(up to three; AI may route elsewhere)</span></legend>
+${options.map((m) => `<label class="vi-check"><input type="checkbox" name="models" value="${esc(m)}" checked><span>${esc(m)}</span></label>`).join('')}
+</fieldset>
+<div class="vi-actions"><button class="vi-btn" type="submit"${signedIn ? '' : ' disabled'}>Compare on this image</button><a class="vi-btn vi-btn-quiet" href="/ask">Ask one model</a></div>
+</form>
+</div>`;
+}
+
+function uiCheck({ signedIn = false, problem = null, result = null, values = {} } = {}) {
+ return `<div class="vi-wrap" id="vi-ui-check">
+<header class="vi-page-h"><p class="vi-kicker">The UI check</p><h1>Find what a person would see</h1>
+<p class="vi-lede">Overflow, cut-off text, low contrast, overlapping boxes and layering faults. Paste generated HTML, name a public page URL, or upload a screenshot. The static pass does not execute JavaScript and does not fetch remote stylesheets; it says so in the result.</p></header>
+${problem ? `<p class="vi-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+<div class="vi-two-col">
+<form class="vi-form vi-card" id="vi-ui-form" method="post" action="/ui-check" enctype="multipart/form-data">
+<label class="vi-field"><span>HTML</span><textarea name="html" rows="10" maxlength="2000000" placeholder="<!doctype html>…">${esc(values.html || '')}</textarea></label>
+<label class="vi-field"><span>…or a public page URL</span><input type="url" name="url" value="${esc(values.url || '')}" placeholder="https://openvibe.vision/" maxlength="2048"></label>
+<div class="vi-field-grid">
+<label class="vi-field"><span>…or a screenshot</span><input type="file" name="image" accept="image/*"></label>
+<label class="vi-field"><span>Viewport width</span><input type="number" name="viewport_w" min="160" max="10000" value="${esc(values.viewport_w || 1280)}"></label>
+<label class="vi-field"><span>Viewport height</span><input type="number" name="viewport_h" min="160" max="10000" value="${esc(values.viewport_h || 800)}"></label>
+</div>
+<div class="vi-actions"><button class="vi-btn" type="submit">Check it</button><a class="vi-btn vi-btn-quiet" href="/developers">Use the API</a></div>
+<p class="vi-fine">${signedIn ? 'The result is saved to your account.' : 'The result is shown here and not saved. <a href="/auth/login?next=%2Fui-check">Sign in</a> to keep it.'}</p>
+</form>
+${result ? resultPanel(result) : '<aside class="vi-card vi-result vi-muted" id="vi-ui-empty"><h2>No page checked yet</h2><p>Start with a screenshot at 390 px wide, or paste a generated HTML document. Findings are returned as data: kind, severity, selector, detail and evidence.</p></aside>'}
+</div>
+</div>`;
+}
+
+function resultPanel(r) {
+ return `<aside class="vi-card vi-result" id="vi-ui-result">
+<h2>${esc(r.summary || (r.findings && r.findings.length ? `${r.findings.length} findings` : 'No faults found'))}</h2>
+<dl class="vi-facts"><div><dt>Engine</dt><dd>${esc(r.engine || 'static-html')}</dd></div><div><dt>Viewport</dt><dd>${Number(r.viewport && r.viewport.w) || 0} × ${Number(r.viewport && r.viewport.h) || 0}</dd></div><div><dt>Findings</dt><dd>${Number(r.findings && r.findings.length) || 0}</dd></div><div><dt>Source</dt><dd>${esc(r.source || 'html')}</dd></div></dl>
+${findingsTable(r.findings || [])}
+${(r.warnings || []).length ? `<ul class="vi-warnings">${r.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : ''}
+</aside>`;
+}
+
+function findingsTable(findings = []) {
+ if (!findings.length) return '<p class="vi-note is-good" role="status">No overflow, cut-off text, low contrast, overlap or layering fault was found by this pass.</p>';
+ return `<div class="vi-table-wrap"><table class="vi-table" id="vi-findings"><thead><tr><th scope="col">Fault</th><th scope="col">Severity</th><th scope="col">Selector</th><th scope="col">What</th></tr></thead><tbody>${findings.slice(0, 200).map((f) => `<tr class="vi-finding vi-finding-${esc(f.kind)} is-${esc(f.severity)}"><td><b>${esc(f.kind)}</b></td><td>${esc(f.severity)}</td><td><code>${esc(f.selector)}</code></td><td>${esc(f.detail)}</td></tr>`).join('')}</tbody></table></div>`;
+}
+
+function answer({ a, mine = false } = {}) {
+ const samples = a.answer && Array.isArray(a.answer.samples) ? a.answer.samples : null;
+ const words = a.answer && Array.isArray(a.answer.words) ? a.answer.words : null;
+ return `<div class="vi-wrap" id="vi-answer" data-answer="${esc(a.id)}">
+<header class="vi-page-h"><p class="vi-kicker">${esc(a.task)} · ${mine ? 'yours' : 'staff view'}</p><h1>${esc(a.model ? MODEL_LABEL(a) : 'The local reader')}</h1>
+${a.question ? `<p class="vi-question">${esc(a.question)}</p>` : ''}</header>
+<section class="vi-answer-card vi-card">
+${a.text ? `<pre class="vi-answer-text">${esc(a.text)}</pre>` : '<p class="vi-muted">No answer text was stored.</p>'}
+<dl class="vi-facts">
+<div><dt>Engine</dt><dd>${esc(a.answer && a.answer.engine ? a.answer.engine : 'local')}</dd></div>
+<div><dt>Model</dt><dd>${esc(MODEL_LABEL(a))}</dd></div>
+<div><dt>Why</dt><dd>${esc(a.reason || '—')}</dd></div>
+<div><dt>Cost</dt><dd>${esc(Number(a.cost_usd) || 0)} USD</dd></div>
+<div><dt>Time</dt><dd>${Number(a.latency_ms) || 0} ms</dd></div>
+<div><dt>Image</dt><dd><code>${esc(a.image_sha256)}</code> · ${esc(a.image_mime)} · ${Number(a.image_bytes) || 0} bytes</dd></div>
+<div><dt>Asked</dt><dd>${time(a.created_at)}</dd></div>
+</dl>
+</section>
+${samples ? `<section class="vi-section" id="vi-compare-result"><h2>Side by side</h2><div class="vi-table-wrap"><table class="vi-table"><thead><tr><th scope="col">Engine</th><th scope="col">Requested</th><th scope="col">Answered by</th><th scope="col">Cost</th><th scope="col">Time</th><th scope="col">Answer</th></tr></thead><tbody>${samples.map((s) => `<tr><td>${esc(s.engine)}</td><td>${esc(s.requested_model || '—')}</td><td>${esc(s.model ? `${s.provider || ''}:${s.model}` : '—')}</td><td>${esc(Number(s.cost_usd) || 0)}</td><td>${Number(s.latency_ms) || 0} ms</td><td>${s.ok ? `<span class="vi-sample-text">${esc(s.text || '')}</span>` : `<span class="vi-note is-bad">${esc(s.error_code || 'failed')}</span>`}</td></tr>`).join('')}</tbody></table></div></section>` : ''}
+${words && words.length ? `<section class="vi-section" id="vi-words"><h2>Word boxes</h2><p class="vi-fine">${words.length} words with their confidence and position; the screenshot UI check uses the same boxes.</p><div class="vi-table-wrap"><table class="vi-table"><thead><tr><th scope="col">Word</th><th scope="col">Confidence</th><th scope="col">x, y</th><th scope="col">w × h</th></tr></thead><tbody>${words.slice(0, 200).map((w) => `<tr><td>${esc(w.text)}</td><td>${esc(w.conf)}</td><td>${Number(w.x) || 0}, ${Number(w.y) || 0}</td><td>${Number(w.w) || 0} × ${Number(w.h) || 0}</td></tr>`).join('')}</tbody></table></div></section>` : ''}
+<div class="vi-actions"><a class="vi-btn" href="/ask">Ask another</a><a class="vi-btn vi-btn-quiet" href="/compare">Compare models</a></div>
+</div>`;
+}
+
+function uiCheckResult({ c, mine = false } = {}) {
+ return `<div class="vi-wrap" id="vi-check" data-check="${esc(c.id)}">
+<header class="vi-page-h"><p class="vi-kicker">UI check · ${mine ? 'yours' : 'staff view'}</p><h1>${esc(c.summary && c.summary.summary ? c.summary.summary : 'A UI check')}</h1>
+<p class="vi-lede">${esc(c.engine)} · ${Number(c.viewport.w)} × ${Number(c.viewport.h)} · ${esc(c.source)}${c.url ? ` · <a href="${esc(c.url)}" rel="nofollow noopener">${esc(c.url)}</a>` : ''}</p></header>
+${findingsTable(c.findings)}
+${(c.summary && c.summary.warnings || []).length ? `<ul class="vi-warnings">${c.summary.warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul>` : ''}
+<div class="vi-actions"><a class="vi-btn" href="/ui-check">Check another</a><a class="vi-btn vi-btn-quiet" href="/developers">Use the API</a></div>
+</div>`;
+}
+
+function developers({ ai = false, models = [] } = {}) {
+ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
+ return `<div class="vi-wrap" id="vi-developers">
+<header class="vi-page-h"><p class="vi-kicker">Developers</p><h1>The Vision API</h1>
+<p class="vi-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>vision_at</code> session cookie or a Network user token as a Bearer. A service token needs the capability on the route; Design's Arena calls the UI check with <code>vision.ui.check</code>.</p></header>
+<section><h2>Ask, read, compare</h2><dl class="vi-eps">
+${ep('POST', '/api/v1/ask (alias /api/v1/describe)', 'One image and a question: <code>{ task, question, mode, image_url }</code>, a multipart <code>image</code>, or an <code>image</code> data URL. Local OCR answers a read question first, otherwise OpenVibe.AI. Capability: <code>vision.ask</code>.')}
+${ep('POST', '/api/v1/ocr', 'The local Tesseract reader. <code>fallback=1</code> asks AI only when no text is found and the caller is signed in or holds <code>vision.ocr</code>.')}
+${ep('POST', '/api/v1/compare', 'The same image through local OCR and one to three AI hints (<code>models</code>). Returns each engine\'s answer, provider, model, cost and latency. Capability: <code>vision.compare</code>.')}
+${ep('POST', '/api/v1/ui-check', 'HTML, URL or screenshot with <code>viewport</code>. Returns <code>findings</code>: overflow, cut_off, contrast, overlap and layering. Capability: <code>vision.ui.check</code>.')}
+${ep('GET', '/api/v1/answers/:id', 'One saved answer, for its owner or a service holding the route capability.')}
+${ep('GET', '/api/v1/ui-checks/:id', 'One saved UI check, for its owner or a service holding <code>vision.ui.check</code>.')}
+${ep('GET', '/api/v1/models', 'The AI route\'s offered provider:model hints and the local engine. No subject data.')}
+${ep('GET', '/api/v1/benchmarks?task=&limit=', 'Aggregate compare samples: task, engine, provider, model, samples, mean latency and mean cost. No subject data.')}
+</dl></section>
+<section id="vi-capabilities"><h2>Capabilities and grants</h2>
+<p class="vi-fine">Vision needs no new AI capability: outgoing calls are <code>ai.run.create</code> to OpenVibe.AI, audience <code>openvibe.ai</code>, namespace <code>ai.*</code>. The capabilities Vision offers are <code>vision.ask</code>, <code>vision.ocr</code>, <code>vision.compare</code> and <code>vision.ui.check</code>; the main session registers the manifests and grants them to Design (the Arena) and any other caller.</p>
+<pre class="vi-code">POST /api/v1/ui-check
+Authorization: Bearer <Network service token for openvibe.vision>
+Content-Type: application/json
+
+{ "html": "<!doctype html>…", "viewport": { "w": 390, "h": 844 } }</pre>
+</section>
+<section id="vi-limits"><h2>What leaves this host</h2>
+<p class="vi-fine">The local OCR engine and the UI check never leave the host. Only an ask, compare or OCR fallback sends the normalised image to OpenVibe.AI; AI's own routing, quotas and provenance apply. Vision holds no provider key. No image bytes are stored, and no face recognition is ever used. Public AI text is passed through <code>noLooks</code>; a look-naming answer is replaced.</p>
+<p class="vi-fine">AI on this host: ${ai ? 'configured' : 'not configured (local engines only)'}. Model hints: ${esc((models || []).join(', ') || 'from AI\'s routes at request time')}.</p>
+</section>
+<section id="vi-search"><h2>Search</h2><p class="vi-fine">v1 has no public objects: answers and UI checks belong to the caller and are not indexed, so Vision writes no Search document and has no outbox. The v2 Arena's public leaderboards are where public records will begin.</p></section>
+</div>`;
+}
+
+function notFound() {
+ return `<div class="vi-wrap vi-page-pad vi-narrow"><div class="vi-empty" id="vi-not-found"><p class="vi-kicker">404</p><h1>This answer is not here</h1><p>It may belong to another person, or the address was never one of ours.</p><a class="vi-btn" href="/">Back to Vision</a></div></div>`;
+}
+
+module.exports = {
+ home, ask, ocr, compare, uiCheck, answer, uiCheckResult, developers, notFound, signIn, formError,
+ resultPanel, findingsTable, motif,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+
+/**
+ * OpenVibe.AI is the only model gateway Vision uses. There is no provider key here, no direct provider URL, and
+ * no paid provider of Vision's own: a first-party service token (client `vision`, audience `openvibe.ai`,
+ * capability `ai.run.create`, namespace `ai.*`) reaches the OpenAI-compatible endpoint, so AI's routing, quotas,
+ * cache, provenance and cost accounting all apply. `x_openvibe` is kept on every answer, because the answer must
+ * say which model answered and why.
+ *
+ * The model in the request is a hint only: AI's router is the authority and may answer from a pool or a fallback.
+ * The provider and model Vision reports are always the ones AI says answered, never the hint.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+const { cleanAnswer } = require('../text/no-looks');
+
+class AiError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code);
+ this.name = 'AiError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail || code;
+ }
+}
+
+const TASK_PROMPTS = {
+ ask: 'Answer the question about the image precisely. Say when the image does not show the answer. If the answer is a count, give the number and name what you counted.',
+ describe: 'Describe the image for someone who cannot see it: the scene, the objects, any visible text, and what is happening. Do not describe anyone\'s appearance.',
+ identify: 'Identify what the image shows. Give the most specific answer the picture supports, and say what is uncertain.',
+ count: 'Count what the question asks about. Give the number first, then list each item you counted.',
+ translate: 'Read the visible text and translate it into the target language. Give the original text, then the translation.',
+ ocr: 'Read every visible piece of text in the image, verbatim, preserving line breaks and reading order. Do not summarize and do not add anything that is not written.',
+ compare: 'Answer the question about the image precisely, for a side-by-side comparison. Say when the image does not show the answer.',
+};
+
+const NO_LOOKS = 'Never describe a person\'s body, face, hair, clothing, age, race, gender, disability or guessed identity. Never identify, name or recognise a person, and never use a face as identity. If the answer would need that, say you cannot describe people\'s appearance and answer about the rest of the picture.';
+
+function textOf(content) {
+ if (typeof content === 'string') return content;
+ if (Array.isArray(content)) {
+ return content.map((p) => {
+ if (!p || typeof p !== 'object') return '';
+ if (typeof p.text === 'string') return p.text;
+ if (p.type === 'output_text' && typeof p.text === 'string') return p.text;
+ return '';
+ }).join('').trim();
+ }
+ return '';
+}
+
+function createAi({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
+ const ai = config.ai;
+ const tokenClient = config.oauth.clientSecret
+ ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl || config.networkUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: ai.audience,
+ scope: ai.scope,
+ fetchImpl,
+ })
+ : null;
+ const enabled = Boolean(ai.url && tokenClient);
+
+ async function call(method, path, body, { retried = false } = {}) {
+ if (!enabled) throw new AiError(503, 'vision.ai_unavailable', 'OpenVibe.AI is not configured: OV_OAUTH_CLIENT_SECRET is unset or OV_AI_URL is empty');
+ let headers;
+ try { headers = { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenClient.authHeaders()) }; }
+ catch { throw new AiError(502, 'vision.ai_token_failed', 'OpenVibe.Network did not issue an AI token for Vision'); }
+ let res;
+ try {
+ res = await fetchImpl(`${String(ai.url).replace(/\/+$/, '')}${path}`, {
+ method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(ai.timeoutMs),
+ });
+ } catch (err) {
+ throw new AiError(502, 'vision.ai_unreachable', err && err.name === 'TimeoutError' ? 'OpenVibe.AI did not answer in time' : 'OpenVibe.AI could not be reached');
+ }
+ if (res.status === 401 && !retried) {
+ try { tokenClient.invalidate(); } catch { /* older client */ }
+ return call(method, path, body, { retried: true });
+ }
+ const json = await res.json().catch(() => null);
+ if (!res.ok) {
+ const err = json && json.error && typeof json.error === 'object' ? json.error : null;
+ const code = (err && err.code) || (json && (json.code || json.error)) || `ai.http_${res.status}`;
+ const detail = (err && err.message) || (json && (json.detail || json.message)) || `OpenVibe.AI refused the run (${res.status})`;
+ throw new AiError(res.status >= 500 ? 502 : res.status, String(code).slice(0, 80), String(detail).slice(0, 500));
+ }
+ return json;
+ }
+
+ /** The route's offered provider:model pairs; a hint for the compare slots, never a promise of the answer. */
+ async function offeredModels() {
+ if (!enabled) return [];
+ try {
+ const body = await call('GET', '/api/v1/routes');
+ const routes = Array.isArray(body) ? body : (body && Array.isArray(body.routes) ? body.routes : []);
+ const chat = routes.filter((r) => r && (r.capability === 'chat' || /chat|generate/i.test(String(r.key || ''))));
+ const ordered = [...chat.filter((r) => r.key === 'default.chat'), ...chat.filter((r) => r.key !== 'default.chat')];
+ const out = [];
+ const seen = new Set();
+ for (const r of ordered) {
+ for (const c of [r.primary, ...(Array.isArray(r.pinned) ? r.pinned : []), ...(Array.isArray(r.fallbacks) ? r.fallbacks : [])]) {
+ if (!c || !c.provider) continue;
+ const key = `${c.provider}|${c.model || ''}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ provider: String(c.provider).slice(0, 80), model: c.model == null ? null : String(c.model).slice(0, 120) });
+ if (out.length >= 6) return out;
+ }
+ }
+ return out;
+ } catch (err) {
+ log.warn(`[OpenVibe.Vision] AI routes unavailable: ${(err && err.message) || err}`);
+ return [];
+ }
+ }
+
+ /**
+ * One image question through AI. opts: dataUrl, task, question, model (a hint), target.
+ * Returns the cleaned text plus AI's own provenance and usage. Throws AiError.
+ */
+ async function answer({ dataUrl, task = 'ask', question = '', model = null, target = null, maxTokens = null, temperature = null } = {}) {
+ const prompt = TASK_PROMPTS[task] || TASK_PROMPTS.ask;
+ const asked = String(question || '').trim().slice(0, 2000);
+ const body = {
+ // `model` is documented as a hint; AI's router picks and reports. Including it keeps the request honest.
+ ...(model ? { model: String(model).slice(0, 120) } : {}),
+ messages: [
+ { role: 'system', content: `You are OpenVibe.Vision's image reader. ${prompt}\n${NO_LOOKS}\nAnswer plainly and briefly; never invent details the picture does not show.` },
+ { role: 'user', content: [
+ { type: 'text', text: asked || 'Describe this image.' },
+ { type: 'image_url', image_url: { url: dataUrl } },
+ ] },
+ ],
+ max_tokens: Math.min(2000, Math.max(32, Number(maxTokens) || ai.maxTokens)),
+ temperature: temperature == null ? ai.temperature : temperature,
+ };
+ const started = Date.now();
+ const out = await call('POST', '/v1/chat/completions', body);
+ const choice = out && Array.isArray(out.choices) ? out.choices[0] : null;
+ const raw = textOf(choice && choice.message && choice.message.content);
+ if (!raw.trim()) throw new AiError(502, 'vision.ai_empty', 'OpenVibe.AI answered without any text');
+ const x = out.x_openvibe || {};
+ const usage = out.usage || {};
+ return {
+ text: cleanAnswer(raw),
+ model: String(out.model || (x.model || '') || (model || 'openvibe')).slice(0, 120),
+ provider: String(x.provider || '').slice(0, 80) || null,
+ route: 'default.chat',
+ costUsd: Math.max(0, Number(x.cost_usd != null ? x.cost_usd : 0) || 0),
+ ms: Date.now() - started,
+ runId: x.run_id || null,
+ synthetic: Boolean(x.synthetic),
+ usage: { tokens_in: Number(usage.prompt_tokens) || 0, tokens_out: Number(usage.completion_tokens) || 0 },
+ reason: `OpenVibe.AI routed a ${task} run to ${x.provider || 'its pool'}${out.model ? `:${out.model}` : ''}; the answer is cleaned by noLooks`,
+ target: target || null,
+ };
+ }
+
+ return { enabled, answer, offeredModels, call, tokenClient };
+}
+
+module.exports = { createAi, AiError, TASK_PROMPTS, NO_LOOKS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,203 @@
@@ -0,0 +1,203 @@
+'use strict';
+
+/**
+ * Image inputs. An API caller may upload bytes (multipart), send a data: URI, or name a public https URL.
+ * The URL path is fetched only after every resolved address is checked: loopback, link-local, private and
+ * unique-local addresses are refused (SSRF), redirects are re-checked, and the bytes must really be an image.
+ *
+ * Nothing here stores an image. The answer writes only sha256, mime and byte count; OCR gets a temporary file
+ * that is removed in a finally block.
+ */
+const crypto = require('crypto');
+const dns = require('dns').promises;
+const fs = require('fs');
+const net = require('net');
+const os = require('os');
+const path = require('path');
+const sharp = require('sharp');
+
+const DATA_URL_RE = /^data:(image\/(?:png|jpe?g|webp|gif|bmp|tiff|avif));base64,([A-Za-z0-9+/=\s]+)$/i;
+const MIME_BY_MAGIC = [
+ { mime: 'image/png', test: (b) => b.length > 8 && b[0] === 0x89 && b.toString('ascii', 1, 4) === 'PNG' },
+ { mime: 'image/jpeg', test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff },
+ { mime: 'image/webp', test: (b) => b.length > 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP' },
+ { mime: 'image/gif', test: (b) => b.length > 6 && (b.toString('ascii', 0, 6) === 'GIF87a' || b.toString('ascii', 0, 6) === 'GIF89a') },
+ { mime: 'image/bmp', test: (b) => b.length > 2 && b.toString('ascii', 0, 2) === 'BM' },
+ { mime: 'image/tiff', test: (b) => b.length > 4 && (b.toString('ascii', 0, 2) === 'II' || b.toString('ascii', 0, 2) === 'MM') },
+ { mime: 'image/avif', test: (b) => b.length > 12 && b.toString('ascii', 4, 12) === 'ftypavif' },
+];
+
+function isPrivateAddress(address) {
+ if (!net.isIP(address)) return true;
+ if (net.isIPv4(address)) {
+ const p = address.split('.').map(Number);
+ if (p[0] === 10 || p[0] === 127 || p[0] === 0) return true;
+ if (p[0] === 169 && p[1] === 254) return true;
+ if (p[0] === 172 && p[1] >= 16 && p[1] <= 31) return true;
+ if (p[0] === 192 && p[1] === 168) return true;
+ if (p[0] === 100 && p[1] >= 64 && p[1] <= 127) return true;
+ if (p[0] === 198 && (p[1] === 18 || p[1] === 19)) return true;
+ if (p[0] >= 224) return true;
+ return false;
+ }
+ const a = address.toLowerCase();
+ if (a === '::' || a === '::1') return true;
+ if (a.startsWith('fe8') || a.startsWith('fe9') || a.startsWith('fea') || a.startsWith('feb')) return true;
+ if (a.startsWith('fc') || a.startsWith('fd')) return true;
+ if (a.startsWith('ff')) return true;
+ if (a.startsWith('::ffff:')) return isPrivateAddress(a.slice(7));
+ return false;
+}
+
+async function assertPublicUrl(raw, { allowPrivate = false } = {}) {
+ let u;
+ try { u = new URL(String(raw)); } catch { const e = new Error('that image URL is not a URL'); e.code = 'image.invalid_url'; throw e; }
+ if (!/^https?:$/.test(u.protocol)) { const e = new Error('an image URL must be http or https'); e.code = 'image.invalid_url'; throw e; }
+ if (u.username || u.password) { const e = new Error('an image URL may not carry credentials'); e.code = 'image.invalid_url'; throw e; }
+ if (!allowPrivate) {
+ const host = u.hostname.replace(/^\[|\]$/g, '');
+ if (net.isIP(host)) {
+ if (isPrivateAddress(host)) { const e = new Error('that address is private; Vision fetches public images only'); e.code = 'image.private_url'; throw e; }
+ } else {
+ let rows;
+ try { rows = await dns.lookup(host, { all: true, verbatim: true }); } catch { const e = new Error('that image host does not resolve'); e.code = 'image.unreachable'; throw e; }
+ if (!rows.length || rows.some((r) => isPrivateAddress(r.address))) {
+ const e = new Error('that host resolves to a private address; Vision fetches public images only');
+ e.code = 'image.private_url';
+ throw e;
+ }
+ }
+ }
+ return u;
+}
+
+function sniff(buffer) {
+ for (const m of MIME_BY_MAGIC) if (m.test(buffer)) return m.mime;
+ return null;
+}
+
+function decodeDataUrl(dataUrl, maxBytes) {
+ const m = DATA_URL_RE.exec(String(dataUrl || '').trim());
+ if (!m) { const e = new Error('image must be a base64 data:image/png|jpeg|webp|gif|bmp|tiff|avif URL'); e.code = 'image.invalid'; throw e; }
+ const buffer = Buffer.from(m[2].replace(/\s+/g, ''), 'base64');
+ if (!buffer.length) { const e = new Error('the image data is empty'); e.code = 'image.invalid'; throw e; }
+ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }
+ const mime = sniff(buffer);
+ if (!mime) { const e = new Error('the data URL does not contain a supported image'); e.code = 'image.unsupported'; throw e; }
+ return { buffer, mime };
+}
+
+async function readBody(res, maxBytes) {
+ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
+ if (!/^image\//.test(type)) { const e = new Error(`expected an image, got ${type || 'an unknown type'}`); e.code = 'image.not_image'; throw e; }
+ const len = Number(res.headers.get('content-length'));
+ if (Number.isFinite(len) && len > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }
+ const ab = await res.arrayBuffer();
+ const buffer = Buffer.from(ab);
+ if (!buffer.length) { const e = new Error('the image URL answered with no bytes'); e.code = 'image.empty'; throw e; }
+ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }
+ const mime = sniff(buffer);
+ if (!mime) { const e = new Error('the URL did not answer with a supported image'); e.code = 'image.unsupported'; throw e; }
+ return { buffer, mime };
+}
+
+async function fetchImage(rawUrl, { fetchImpl = globalThis.fetch, timeoutMs = 10000, maxBytes = 8 * 1024 * 1024, allowPrivate = false } = {}) {
+ let url = await assertPublicUrl(rawUrl, { allowPrivate });
+ for (let hop = 0; hop < 4; hop++) {
+ let res;
+ try { res = await fetchImpl(url.toString(), { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'image/*', 'User-Agent': 'OpenVibe.Vision/0.1 image reader' } }); }
+ catch (err) { const e = new Error(err && err.name === 'TimeoutError' ? 'the image host did not answer in time' : 'the image host could not be reached'); e.code = err && err.name === 'TimeoutError' ? 'image.timeout' : 'image.unreachable'; throw e; }
+ if (res.status >= 300 && res.status < 400) {
+ const loc = res.headers.get('location');
+ if (!loc) { const e = new Error('the image host redirected without a location'); e.code = 'image.unreachable'; throw e; }
+ url = await assertPublicUrl(new URL(loc, url).toString(), { allowPrivate });
+ continue;
+ }
+ if (!res.ok) { const e = new Error(`the image host answered ${res.status}`); e.code = 'image.unreachable'; throw e; }
+ return await readBody(res, maxBytes);
+ }
+ const e = new Error('too many redirects for that image'); e.code = 'image.unreachable'; throw e;
+}
+
+function sha256(buffer) {
+ return crypto.createHash('sha256').update(buffer).digest('hex');
+}
+
+/**
+ * The one entry point routes use. opts: file (multer buffer), contentType, imageUrl, image (data URL or plain
+ * base64), fetchImpl, timeoutMs, maxMb, allowPrivate.
+ */
+async function loadImage(opts = {}) {
+ const config = opts.config || {};
+ const maxMb = Math.min(20, Math.max(1, Number(opts.maxMb || (config.images && config.images.maxMb) || 8)));
+ const maxBytes = maxMb * 1024 * 1024;
+ const timeoutMs = Number(opts.timeoutMs || (config.images && config.images.timeoutMs) || 10000);
+ const allowPrivate = opts.allowPrivate != null ? opts.allowPrivate : Boolean(config.images && config.images.allowPrivate);
+
+ let buffer; let mime; let source; let sourceUrl = null;
+ if (opts.file) {
+ buffer = Buffer.from(opts.file);
+ mime = sniff(buffer);
+ if (!mime) { const e = new Error('that file is not a supported image'); e.code = 'image.unsupported'; throw e; }
+ if (buffer.length > maxBytes) { const e = new Error('that image is larger than the limit'); e.code = 'image.too_large'; throw e; }
+ source = 'upload';
+ } else if (opts.imageUrl) {
+ const got = await fetchImage(opts.imageUrl, { fetchImpl: opts.fetchImpl, timeoutMs, maxBytes, allowPrivate });
+ buffer = got.buffer; mime = got.mime; source = 'url'; sourceUrl = String(opts.imageUrl).slice(0, 2048);
+ } else if (opts.image) {
+ const got = decodeDataUrl(opts.image, maxBytes);
+ buffer = got.buffer; mime = got.mime; source = 'data';
+ } else {
+ const e = new Error('send an image file, an image data URL, or image_url'); e.code = 'image.required'; throw e;
+ }
+ if (!['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/tiff', 'image/avif'].includes(mime)) {
+ const e = new Error('that image type is not supported'); e.code = 'image.unsupported'; throw e;
+ }
+ // A compressed image can decode to an enormous surface. 40 MP is far beyond a screenshot or a photo a person
+ // asks about, and keeps the local passes (sharp pixels, Tesseract) inside a predictable memory budget.
+ try {
+ const meta = await sharp(buffer, { failOn: 'none', limitInputPixels: 40_000_000 }).metadata();
+ if (!meta || !meta.width || !meta.height) throw new Error('no dimensions');
+ } catch {
+ const e = new Error('that image could not be decoded (or is larger than 40 megapixels)'); e.code = 'image.invalid'; throw e;
+ }
+ return { buffer, mime, bytes: buffer.length, sha256: sha256(buffer), source, sourceUrl };
+}
+
+/** Normalise for a vision model: rotate by EXIF, fit inside 1536 px, JPEG (the form every provider reads). */
+async function forModel(buf) {
+ const out = await sharp(buf, { failOn: 'none', animated: false })
+ .rotate().resize({ width: 1536, height: 1536, fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: 82 }).toBuffer();
+ return { buffer: out, mime: 'image/jpeg', dataUrl: `data:image/jpeg;base64,${out.toString('base64')}` };
+}
+
+/** Normalise for Tesseract: rotate, grayscale, normalise contrast, fit inside 4000 px, PNG. */
+async function forOcr(buf) {
+ const out = await sharp(buf, { failOn: 'none', animated: false })
+ .rotate().resize({ width: 4000, height: 4000, fit: 'inside', withoutEnlargement: true })
+ .grayscale().normalise().png().toBuffer();
+ return { buffer: out, mime: 'image/png' };
+}
+
+/** Metadata for a screenshot answer; no bytes are kept. */
+async function metadata(buf) {
+ const m = await sharp(buf, { failOn: 'none' }).metadata();
+ return { width: Number(m.width) || 0, height: Number(m.height) || 0, format: m.format || null };
+}
+
+async function withTempImage(buffer, ext, fn) {
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openvibe-vision-'));
+ const file = path.join(dir, `image.${ext || 'png'}`);
+ try {
+ await fs.promises.writeFile(file, buffer);
+ return await fn(file);
+ } finally {
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
+ }
+}
+
+module.exports = {
+ loadImage, fetchImage, assertPublicUrl, isPrivateAddress, sniff, sha256,
+ forModel, forOcr, metadata, withTempImage,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * The local reader: Tesseract 5, called as a child process with an argument array (never a shell). It is the
+ * classical OCR step the plan names first; no image leaves this host and no model is called. A missing binary is
+ * an honest 503 (`vision.ocr_unavailable`), not a silent cloud fallback.
+ *
+ * `recognize()` returns the text, the mean word confidence and the word boxes. The UI check's screenshot path
+ * reuses the boxes to find text at an edge or under another box.
+ */
+const { execFile } = require('child_process');
+const { promisify } = require('util');
+const { forOcr, withTempImage } = require('./images');
+
+const run = promisify(execFile);
+const LANG_RE = /^[a-z]{3}(?:\+[a-z]{3}){0,3}$/;
+
+function parseTsv(tsv) {
+ const words = [];
+ const lines = new Map();
+ for (const raw of String(tsv || '').split(/\r?\n/)) {
+ if (!raw || /^level\t/.test(raw)) continue;
+ const c = raw.split('\t');
+ if (c.length < 12) continue;
+ const level = Number(c[0]);
+ if (level !== 5) continue;
+ const text = String(c[11] == null ? '' : c[11]).trim();
+ if (!text) continue;
+ const conf = Number(c[10]);
+ const box = { x: Number(c[6]) || 0, y: Number(c[7]) || 0, w: Number(c[8]) || 0, h: Number(c[9]) || 0 };
+ const key = `${c[1]}/${c[2]}/${c[3]}/${c[4]}`;
+ words.push({ text, conf: Number.isFinite(conf) ? conf : -1, ...box });
+ if (!lines.has(key)) lines.set(key, []);
+ lines.get(key).push({ text, ...box });
+ }
+ const sorted = [...lines.entries()].sort((a, b) => {
+ const ay = Math.min(...a[1].map((w) => w.y)); const by = Math.min(...b[1].map((w) => w.y));
+ if (ay !== by) return ay - by;
+ return Math.min(...a[1].map((w) => w.x)) - Math.min(...b[1].map((w) => w.x));
+ });
+ const text = sorted.map(([, ws]) => ws.sort((a, b) => a.x - b.x).map((w) => w.text).join(' ')).join('\n').trim();
+ const scored = words.filter((w) => w.conf >= 0);
+ const confidence = scored.length ? scored.reduce((n, w) => n + w.conf, 0) / scored.length : 0;
+ return { text, confidence: Math.round(confidence * 10) / 10, words };
+}
+
+function createOcr({ config, log = console } = {}) {
+ const bin = (config && config.ocr && config.ocr.bin) || 'tesseract';
+ const lang = (config && config.ocr && config.ocr.lang) || 'eng';
+ const timeoutMs = (config && config.ocr && config.ocr.timeoutMs) || 20000;
+ let version = null;
+ let checked = false;
+
+ async function available() {
+ if (checked) return version;
+ checked = true;
+ try {
+ const { stdout } = await run(bin, ['--version'], { timeout: 5000, maxBuffer: 256 * 1024, env: { ...process.env, OMP_THREAD_LIMIT: process.env.OMP_THREAD_LIMIT || '1' } });
+ version = String(stdout || '').split('\n')[0].trim().slice(0, 120) || 'tesseract';
+ } catch (err) {
+ version = null;
+ log.warn(`[OpenVibe.Vision] local OCR unavailable (${bin}): ${err && err.code === 'ENOENT' ? 'not installed' : String(err && err.message || err).slice(0, 120)}`);
+ }
+ return version;
+ }
+
+ function language(value) {
+ const l = String(value || lang).trim();
+ return LANG_RE.test(l) ? l : lang;
+ }
+
+ /**
+ * OCR one image buffer. Never throws for a bad image or a missing binary: returns { ok, ... } so a route can
+ * answer a stable problem code.
+ */
+ async function recognize(buffer, { lang: wanted = null } = {}) {
+ const v = await available();
+ if (!v) return { ok: false, code: 'vision.ocr_unavailable', detail: 'the local OCR engine (tesseract) is not installed on this host' };
+ let png;
+ try { png = await forOcr(buffer); }
+ catch { return { ok: false, code: 'vision.image_invalid', detail: 'the image could not be decoded for OCR' }; }
+ const started = Date.now();
+ try {
+ const { stdout } = await withTempImage(png.buffer, 'png', (file) => run(bin, [file, 'stdout', '-l', language(wanted), '--psm', '3', 'tsv'], {
+ timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, encoding: 'utf8',
+ env: { ...process.env, OMP_THREAD_LIMIT: process.env.OMP_THREAD_LIMIT || '1' },
+ }));
+ const parsed = parseTsv(stdout);
+ return { ok: true, engine: 'tesseract', version: v, ms: Date.now() - started, ...parsed };
+ } catch (err) {
+ if (err && err.killed) return { ok: false, code: 'vision.ocr_timeout', detail: 'the local OCR engine ran past its time limit' };
+ log.warn(`[OpenVibe.Vision] local OCR failed: ${String(err && err.message || err).slice(0, 200)}`);
+ return { ok: false, code: 'vision.ocr_failed', detail: 'the local OCR engine could not read that image' };
+ }
+ }
+
+ return { recognize, available, parseTsv, bin, lang };
+}
+
+module.exports = { createOcr, parseTsv };
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -0,0 +1,208 @@
+'use strict';
+
+/**
+ * The v1 engine chooser. Cheapest first, as the plan says: the local OCR reader answers a read task when it can;
+ * otherwise OpenVibe.AI's routed vision model answers. Every result carries the engine/model that actually
+ * answered, the reason it was chosen, the measured latency and the cost AI reported (0 for local).
+ *
+ * compare runs the same image through the local reader and one or more AI hints side by side, records each
+ * sample and stores the aggregate. A model hint is never reported as the answerer: AI's provenance wins.
+ */
+const { AiError } = require('./ai');
+const { forModel } = require('./images');
+const { cleanAnswer } = require('../text/no-looks');
+
+class VisionError extends Error {
+ constructor(status, code, detail, extra = {}) {
+ super(detail || code);
+ this.name = 'VisionError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail || code;
+ this.extra = extra;
+ }
+}
+
+const TASKS = new Set(['ask', 'describe', 'identify', 'count', 'translate', 'ocr', 'compare']);
+const READ_RE = /\b(read|say|says|said|text|word|words|label|labels|sign|signs|receipt|receipts|handwriting|ocr|written|print|printed|translate|translation|caption|menu|address|number)\b/i;
+
+function isReadTask(task, question) {
+ if (task === 'ocr') return true;
+ // Local OCR can read the words, but it cannot translate them: a translate task (or a question that asks for a
+ // translation) goes to the model when auto mode is on. mode=local still returns the reading, and says so.
+ if (task === 'translate') return false;
+ if (/\btranslate|translation\b/i.test(String(question || ''))) return false;
+ return task === 'ask' && READ_RE.test(String(question || ''));
+}
+
+function asTask(value) {
+ const t = String(value || 'ask').toLowerCase();
+ return TASKS.has(t) ? t : null;
+}
+
+function createVision({ config, s, images, ocr, ai, store, log = console }) {
+ const maxAiSamples = 3;
+
+ function localResult(image, task, { question = null, lang = null, startedAt = Date.now() } = {}) {
+ return ocr.recognize(image.buffer, { lang }).then((r) => ({
+ engine: 'local',
+ ok: Boolean(r.ok && String(r.text || '').trim()),
+ text: r.ok ? String(r.text || '').trim() : null,
+ model: r.ok ? 'tesseract' : null,
+ provider: 'local',
+ route: 'local.ocr',
+ reason: r.ok ? 'the local OCR reader answered the read task; no model was called' : null,
+ costUsd: 0,
+ ms: Number(r.ms) || (Date.now() - startedAt),
+ errorCode: r.ok ? null : r.code,
+ detail: r.ok ? null : r.detail,
+ confidence: r.ok ? r.confidence : null,
+ words: r.ok ? r.words : [],
+ version: r.ok ? r.version : null,
+ question,
+ }));
+ }
+
+ async function aiResult(image, task, { question = null, model = null } = {}) {
+ const normal = await forModel(image.buffer);
+ const started = Date.now();
+ try {
+ const a = await ai.answer({ dataUrl: normal.dataUrl, task, question, model });
+ return {
+ engine: 'ai', ok: true, text: a.text, model: a.model, provider: a.provider, route: a.route,
+ reason: a.reason, costUsd: a.costUsd, ms: a.ms || (Date.now() - started), errorCode: null,
+ detail: null, runId: a.runId, synthetic: a.synthetic, usage: a.usage,
+ };
+ } catch (err) {
+ const code = (err && err.code) || 'vision.ai_failed';
+ const detail = (err && (err.detail || err.message)) || 'OpenVibe.AI did not answer';
+ return { engine: 'ai', ok: false, text: null, model: model || null, provider: null, route: null, reason: null, costUsd: 0, ms: Date.now() - started, errorCode: code, detail: String(detail).slice(0, 500) };
+ }
+ }
+
+ /**
+ * ask: one image, one question. `mode`:
+ * auto (default) local OCR for a read task when it finds text, else AI
+ * local local OCR only; never a model
+ * ai AI directly (the caller knows it needs a model), OCR task still prompts a read
+ */
+ async function ask({ requester, subject = null, image, task = 'ask', question = '', mode = 'auto', lang = null, model = null }) {
+ const t = asTask(task);
+ if (!t || t === 'compare') throw new VisionError(400, 'vision.invalid_task', 'task must be ask, describe, identify, count, translate or ocr');
+ if (mode !== 'auto' && mode !== 'local' && mode !== 'ai') throw new VisionError(400, 'vision.invalid_mode', 'mode must be auto, local or ai');
+ const started = Date.now();
+ const q = String(question || '').trim().slice(0, 2000);
+ if (t !== 'describe' && t !== 'ocr' && !q && mode !== 'local') throw new VisionError(400, 'vision.question_required', 'ask, identify, count and translate need a question');
+
+ let local = null;
+ if (mode !== 'ai' && (isReadTask(t, q) || mode === 'local')) {
+ local = await localResult(image, t, { question: q, lang, startedAt: started });
+ } else if (mode === 'local' && t !== 'ocr') {
+ local = await localResult(image, t, { question: q, lang, startedAt: started });
+ }
+
+ let chosen = null;
+ if (mode === 'local' || (local && local.ok && mode === 'auto')) {
+ chosen = local || await localResult(image, t, { question: q, lang, startedAt: started });
+ if (!chosen.ok && mode === 'local') throw new VisionError(502, chosen.errorCode || 'vision.ocr_failed', chosen.detail || 'the local OCR engine could not read that image');
+ } else {
+ if (!ai.enabled) {
+ if (local && local.ok) chosen = local;
+ else throw new VisionError(503, 'vision.ai_unavailable', 'OpenVibe.AI is not configured and the local OCR reader found no text for this task');
+ } else {
+ chosen = await aiResult(image, t, { question: q, model });
+ if (!chosen.ok) {
+ if (local && local.ok) chosen = local;
+ else throw new VisionError(chosen.errorCode === 'vision.ai_unavailable' ? 503 : 502, chosen.errorCode || 'vision.ai_failed', chosen.detail || 'OpenVibe.AI did not answer');
+ }
+ }
+ }
+
+ const latency = Date.now() - started;
+ const answer = {
+ engine: chosen.engine, model: chosen.model, provider: chosen.provider, route: chosen.route,
+ reason: chosen.reason || (chosen.engine === 'local' ? 'the local OCR reader was the cheapest engine that could answer' : 'OpenVibe.AI routed the task to the model that answered'),
+ confidence: chosen.confidence == null ? null : chosen.confidence,
+ ...(chosen.words && chosen.words.length ? { words: chosen.words.slice(0, 200) } : {}),
+ ...(chosen.runId ? { run_id: chosen.runId } : {}),
+ ...(chosen.usage ? { usage: chosen.usage } : {}),
+ };
+ const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);
+ const saved = await store.saveAnswer({
+ subject, requester, task: t, question: q || null, mode, image,
+ text, answer, model: chosen.model, provider: chosen.provider, route: chosen.route, reason: answer.reason,
+ costUsd: chosen.costUsd, latencyMs: latency,
+ });
+ return { ...saved, engine: chosen.engine, confidence: chosen.confidence == null ? null : chosen.confidence };
+ }
+
+ /** OCR: local first; `fallback: true` asks AI only when the local reader found nothing usable. */
+ async function ocrImage({ requester, subject = null, image, lang = null, fallback = false, model = null }) {
+ const started = Date.now();
+ const local = await localResult(image, 'ocr', { lang, startedAt: started });
+ let chosen = local;
+ if ((!local.ok || !String(local.text || '').trim()) && fallback) {
+ if (ai.enabled) chosen = await aiResult(image, 'ocr', { question: 'Read all visible text verbatim.', model });
+ if (!chosen.ok && local.ok) chosen = local;
+ }
+ if (!chosen.ok) {
+ const status = chosen.errorCode === 'vision.ai_unavailable' || chosen.errorCode === 'vision.ocr_unavailable' ? 503 : 502;
+ throw new VisionError(status, chosen.errorCode || 'vision.ocr_failed', chosen.detail || 'no local OCR text was found');
+ }
+ const text = chosen.engine === 'local' ? chosen.text : cleanAnswer(chosen.text);
+ const answer = {
+ engine: chosen.engine, model: chosen.model, provider: chosen.provider, route: chosen.route,
+ reason: chosen.reason || (chosen.engine === 'local' ? 'the local OCR reader answered first' : 'the local OCR reader found no text, so OpenVibe.AI was asked'),
+ confidence: chosen.confidence == null ? null : chosen.confidence,
+ ...(chosen.words && chosen.words.length ? { words: chosen.words.slice(0, 500) } : {}),
+ ...(chosen.runId ? { run_id: chosen.runId } : {}),
+ };
+ const saved = await store.saveAnswer({
+ subject, requester, task: 'ocr', question: null, mode: fallback ? 'auto' : 'local', image,
+ text, answer, model: chosen.model, provider: chosen.provider, route: chosen.route, reason: answer.reason,
+ costUsd: chosen.costUsd, latencyMs: Date.now() - started,
+ });
+ return { ...saved, engine: chosen.engine, confidence: chosen.confidence == null ? null : chosen.confidence };
+ }
+
+ /**
+ * compare: local OCR and AI runs on the same image. `models` are hints; when empty, the AI route's offered
+ * models are read (up to three) and one AI run is made for each distinct hint. AI's provenance is recorded.
+ */
+ async function compare({ requester, subject = null, image, task = 'ask', question = '', models = [], lang = null }) {
+ const t = asTask(task);
+ if (!t || t === 'compare') throw new VisionError(400, 'vision.invalid_task', 'compare needs ask, describe, identify, count, translate or ocr');
+ const started = Date.now();
+ const q = String(question || '').trim().slice(0, 2000) || 'Read this image and describe what it shows.';
+ const samples = [];
+ const local = await localResult(image, t, { question: q, lang, startedAt: started });
+ samples.push({ engine: 'local', requestedModel: 'tesseract', model: local.model, provider: local.provider, route: local.route, ok: local.ok, text: local.text, costUsd: local.costUsd, ms: local.ms, errorCode: local.errorCode });
+
+ if (ai.enabled) {
+ let hints = (Array.isArray(models) ? models : []).map((m) => String(m || '').trim()).filter(Boolean).slice(0, maxAiSamples);
+ if (!hints.length) {
+ const offered = await ai.offeredModels();
+ hints = (offered.length ? offered : [{ provider: 'ai', model: null }])
+ .map((o) => (o.provider && o.provider !== 'ai' ? `${o.provider}:${o.model || ''}` : (o.model || 'openvibe')))
+ .filter(Boolean).slice(0, maxAiSamples);
+ }
+ for (const hint of hints) {
+ const a = await aiResult(image, t, { question: q, model: hint });
+ samples.push({ engine: 'ai', requestedModel: hint, model: a.model, provider: a.provider, route: a.route, ok: a.ok, text: a.ok ? cleanAnswer(a.text) : null, costUsd: a.costUsd, ms: a.ms, errorCode: a.errorCode });
+ }
+ }
+
+ const saved = await store.saveCompare({ subject, requester, task: t, image, question: q, samples });
+ return {
+ ...saved.answer,
+ engine: 'compare',
+ compare: saved.samples.map((x) => ({ ...x, text: x.text == null ? null : (x.engine === 'local' ? x.text : cleanAnswer(x.text)) })),
+ local: { ok: local.ok, model: local.model, confidence: local.confidence == null ? null : local.confidence, error_code: local.errorCode || null },
+ reason: `${saved.samples.length} engines ran on the same image; local OCR is free and AI runs report their own cost and latency`,
+ };
+ }
+
+ return { ask, ocr: ocrImage, compare, isReadTask, TASKS };
+}
+
+module.exports = { createVision, VisionError, isReadTask, asTask, TASKS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,674 @@
@@ -0,0 +1,674 @@
+'use strict';
+
+/**
+ * The UI check (plan §5.8 and §3.11): overflow, cut-off text, low contrast, overlap and layering faults.
+ *
+ * Two local passes, no browser, no model, no paid service:
+ *
+ * static-html parses the document and its inline CSS, estimates the boxes and computes WCAG contrast from the
+ * colours. It is the pass a generated HTML page or a URL gets. JavaScript is not executed, and a
+ * remote stylesheet is not fetched: those are stated as limits in `warnings`.
+ * screenshot reads a PNG/JPEG screenshot with the local OCR boxes and sharp's pixels: text at an edge is
+ * reported as cut off, and the contrast inside each word box is measured. Layering cannot be seen
+ * from pixels, and the answer says so.
+ *
+ * Findings are data: { kind, severity, selector, detail, evidence }. The API is what Design's Arena calls.
+ */
+const sharp = require('sharp');
+const { assertPublicUrl } = require('./images');
+
+const BLOCK = new Set(['html', 'body', 'div', 'p', 'section', 'article', 'header', 'footer', 'main', 'aside', 'nav', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figure', 'figcaption', 'form', 'fieldset', 'legend', 'blockquote', 'pre', 'hr', 'address', 'details', 'summary', 'dialog', 'canvas', 'video', 'img']);
+const INLINE = new Set(['span', 'a', 'b', 'strong', 'i', 'em', 'u', 'small', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'label', 'button', 'input', 'select', 'textarea', 'br', 'time', 'abbr', 'cite', 'q', 'mark', 'svg', 'path']);
+const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
+const HIDDEN_TAGS = new Set(['script', 'style', 'template', 'head', 'title', 'meta', 'link', 'base', 'noscript']);
+const NAMED = {
+ transparent: [0, 0, 0, 0], white: [255, 255, 255, 1], black: [0, 0, 0, 1], red: [255, 0, 0, 1],
+ green: [0, 128, 0, 1], blue: [0, 0, 255, 1], gray: [128, 128, 128, 1], grey: [128, 128, 128, 1],
+ silver: [192, 192, 192, 1], maroon: [128, 0, 0, 1], olive: [128, 128, 0, 1], lime: [0, 255, 0, 1],
+ aqua: [0, 255, 255, 1], teal: [0, 128, 128, 1], navy: [0, 0, 128, 1], fuchsia: [255, 0, 255, 1],
+ purple: [128, 0, 128, 1], orange: [255, 165, 0, 1], yellow: [255, 255, 0, 1], pink: [255, 192, 203, 1],
+ brown: [165, 42, 42, 1], cyan: [0, 255, 255, 1], magenta: [255, 0, 255, 1], gold: [255, 215, 0, 1],
+ darkgray: [169, 169, 169, 1], darkgrey: [169, 169, 169, 1], dimgray: [105, 105, 105, 1], dimgrey: [105, 105, 105, 1],
+ lightgray: [211, 211, 211, 1], lightgrey: [211, 211, 211, 1], whitesmoke: [245, 245, 245, 1], gainsboro: [220, 220, 220, 1],
+};
+const MAX_NODES = 6000;
+
+function decodeEntities(s) {
+ return String(s == null ? '' : s)
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
+ .replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
+ .replace(/"/gi, '"').replace(/'|'/gi, "'");
+}
+
+function parseAttrs(s) {
+ const out = {};
+ const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
+ let m;
+ while ((m = re.exec(s || ''))) {
+ const k = m[1].toLowerCase();
+ out[k] = decodeEntities(m[2] != null ? m[2] : (m[3] != null ? m[3] : (m[4] != null ? m[4] : '')));
+ }
+ return out;
+}
+
+/** A forgiving HTML tree. Script/style/head content is not part of the visible tree; <style> text is collected. */
+function parseHtml(html) {
+ const root = { tag: '#document', attrs: {}, classes: [], children: [], text: '', parent: null };
+ const stack = [root];
+ const styles = [];
+ const warnings = [];
+ let nodes = 0;
+ let i = 0;
+ const s = String(html || '');
+ const tagRe = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<!doctype[^>]*>|<![^>]*>|<\/([a-zA-Z][\w:-]*)\s*>|<([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>/gi;
+ let m;
+ const addText = (text) => {
+ if (!text) return;
+ const top = stack[stack.length - 1];
+ if (!top || HIDDEN_TAGS.has(top.tag)) return;
+ const clean = decodeEntities(text);
+ if (clean.trim()) top.text = `${top.text || ''}${top.text ? ' ' : ''}${clean.replace(/\s+/g, ' ').trim()}`;
+ };
+ while ((m = tagRe.exec(s))) {
+ addText(s.slice(i, m.index));
+ i = tagRe.lastIndex;
+ if (m[0].startsWith('<!--') || /^<!/.test(m[0])) continue;
+ if (m[1]) {
+ const tag = m[1].toLowerCase();
+ for (let j = stack.length - 1; j > 0; j--) {
+ if (stack[j].tag === tag) { stack.length = j; break; }
+ }
+ continue;
+ }
+ if (!m[2]) continue;
+ const tag = m[2].toLowerCase();
+ const attrs = parseAttrs(m[3] || '');
+ if (tag === 'style') {
+ const end = s.toLowerCase().indexOf('</style', tagRe.lastIndex);
+ if (end >= 0) {
+ styles.push(s.slice(tagRe.lastIndex, end));
+ tagRe.lastIndex = end;
+ i = end;
+ }
+ continue;
+ }
+ if (nodes++ > MAX_NODES) { warnings.push('the document has more than 6000 elements; the pass stopped early'); break; }
+ const node = {
+ tag, attrs, id: attrs.id || null,
+ classes: String(attrs.class || '').split(/\s+/).filter(Boolean),
+ children: [], text: '', parent: stack[stack.length - 1],
+ };
+ node.parent.children.push(node);
+ if (HIDDEN_TAGS.has(tag)) { stack.push(node); continue; }
+ if (VOID.has(tag) || m[4] === '/') continue;
+ stack.push(node);
+ }
+ addText(s.slice(i));
+ // A fragment (or a document without body) still gets the body box, so `body { … }` rules apply.
+ if (!root.children.some((c) => c.tag === 'body')) {
+ const body = { tag: 'body', attrs: {}, id: null, classes: [], children: root.children, text: '', parent: root };
+ for (const c of body.children) c.parent = body;
+ root.children = [body];
+ }
+ if (!styles.length) warnings.push('no inline <style> block: only inline style attributes were read');
+ warnings.push('JavaScript was not executed and remote stylesheets were not fetched; this is a static layout pass');
+ return { root, styles, warnings };
+}
+
+function parseColor(value) {
+ const v = String(value == null ? '' : value).trim().toLowerCase();
+ if (!v || v === 'currentcolor' || v === 'inherit' || v === 'initial' || v === 'unset') return null;
+ if (NAMED[v]) return NAMED[v].slice();
+ if (v === 'transparent') return [0, 0, 0, 0];
+ let m = /^#([0-9a-f]{3,8})$/i.exec(v);
+ if (m) {
+ let h = m[1];
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
+ if (h.length === 4) h = h.split('').map((c) => c + c).join('');
+ if (h.length === 6 || h.length === 8) {
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1];
+ }
+ }
+ m = /^rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(v);
+ if (m) {
+ const n = (x, max) => (String(x).endsWith('%') ? Math.round(parseFloat(x) * max / 100) : parseFloat(x));
+ const a = m[4] == null ? 1 : (String(m[4]).endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]));
+ return [n(m[1], 255), n(m[2], 255), n(m[3], 255), Math.max(0, Math.min(1, Number.isFinite(a) ? a : 1))];
+ }
+ m = /^hsla?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i.exec(v);
+ if (m) {
+ const h = ((parseFloat(m[1]) % 360) + 360) % 360 / 360;
+ const sat = parseFloat(m[2]) / 100; const li = parseFloat(m[3]) / 100;
+ const q = li < 0.5 ? li * (1 + sat) : li + sat - li * sat;
+ const p = 2 * li - q;
+ const hue = (t) => { let x = t; if (x < 0) x += 1; if (x > 1) x -= 1; if (x < 1 / 6) return p + (q - p) * 6 * x; if (x < 1 / 2) return q; if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; return p; };
+ const a = m[4] == null ? 1 : (String(m[4]).endsWith('%') ? parseFloat(m[4]) / 100 : parseFloat(m[4]));
+ return [Math.round(hue(h + 1 / 3) * 255), Math.round(hue(h) * 255), Math.round(hue(h - 1 / 3) * 255), a];
+ }
+ return null;
+}
+
+const colorCss = (c) => (c ? `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${Math.round(c[3] * 100) / 100})` : 'unknown');
+const luminance = (c) => {
+ const f = (n) => { const x = n / 255; return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); };
+ return 0.2126 * f(c[0]) + 0.7152 * f(c[1]) + 0.0722 * f(c[2]);
+};
+function contrastRatio(fg, bg) {
+ const a = fg[3] == null ? 1 : fg[3];
+ const blended = [0, 1, 2].map((i) => Math.round(fg[i] * a + bg[i] * (1 - a)));
+ const l1 = luminance(blended); const l2 = luminance(bg);
+ const hi = Math.max(l1, l2); const lo = Math.min(l1, l2);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+function parseLength(value, { fontSize = 16, parent = 16, root = 16, percentBase = 0 } = {}) {
+ const v = String(value == null ? '' : value).trim().toLowerCase();
+ if (!v || v === 'auto' || v === 'none' || v === 'normal') return null;
+ if (v.endsWith('px')) return parseFloat(v);
+ if (v.endsWith('rem')) return parseFloat(v) * root;
+ if (v.endsWith('em')) return parseFloat(v) * parent;
+ if (v.endsWith('%')) return parseFloat(v) * percentBase / 100;
+ if (v.endsWith('vw')) return parseFloat(v) * percentBase / 100;
+ if (v.endsWith('vh')) return parseFloat(v) * percentBase / 100;
+ if (v.endsWith('pt')) return parseFloat(v) * 4 / 3;
+ if (/^[\d.]+$/.test(v)) return parseFloat(v);
+ return null;
+}
+
+function parseDeclarations(text) {
+ const out = {};
+ for (const raw of String(text || '').split(';')) {
+ const i = raw.indexOf(':');
+ if (i < 0) continue;
+ const k = raw.slice(0, i).trim().toLowerCase();
+ const v = raw.slice(i + 1).trim();
+ if (k && v) out[k] = v.replace(/\s*!important\s*$/i, '');
+ }
+ if (out.background && !out['background-color']) {
+ const simple = String(out.background).trim();
+ if (parseColor(simple)) out['background-color'] = simple;
+ else if (/^#|^rgb|^hsl/i.test(simple) && !/gradient|url\(/i.test(simple)) out['background-color'] = simple.split(/\s+/)[0];
+ out['background-image'] = /gradient|url\(/i.test(simple) ? simple : out['background-image'];
+ }
+ if (out.margin) { const p = out.margin.split(/\s+/); out['margin-top'] = p[0]; out['margin-right'] = p[1] || p[0]; out['margin-bottom'] = p[2] || p[0]; out['margin-left'] = p[3] || p[1] || p[0]; }
+ if (out.padding) { const p = out.padding.split(/\s+/); out['padding-top'] = p[0]; out['padding-right'] = p[1] || p[0]; out['padding-bottom'] = p[2] || p[0]; out['padding-left'] = p[3] || p[1] || p[0]; }
+ if (out.inset) { const p = out.inset.split(/\s+/); out.top = p[0]; out.right = p[1] || p[0]; out.bottom = p[2] || p[0]; out.left = p[3] || p[1] || p[0]; }
+ return out;
+}
+
+function mediaMatches(condition, viewport) {
+ const c = String(condition || '').toLowerCase();
+ if (!c || /\ball\b|\bscreen\b/.test(c)) {
+ const max = /max-width\s*:\s*([\d.]+)px/.exec(c); const min = /min-width\s*:\s*([\d.]+)px/.exec(c);
+ if (max && viewport.w > parseFloat(max[1])) return false;
+ if (min && viewport.w < parseFloat(min[1])) return false;
+ return true;
+ }
+ return false;
+}
+
+/** The inline stylesheets' rules, in source order. @media is applied only when the viewport matches. */
+function parseRules(css, viewport) {
+ const rules = [];
+ const warnings = [];
+ let order = 0;
+ const text = String(css || '').replace(/\/\*[\s\S]*?\*\//g, '');
+ const parseBlock = (src) => {
+ let i = 0;
+ while (i < src.length) {
+ const open = src.indexOf('{', i);
+ if (open < 0) break;
+ const selector = src.slice(i, open).trim();
+ let depth = 1; let j = open + 1;
+ while (j < src.length && depth) { if (src[j] === '{') depth++; else if (src[j] === '}') depth--; j++; }
+ const body = src.slice(open + 1, j - 1);
+ if (/^@media\b/i.test(selector)) {
+ const cond = selector.replace(/^@media\s*/i, '');
+ if (mediaMatches(cond, viewport)) parseBlock(body);
+ } else if (/^@supports\b/i.test(selector)) {
+ parseBlock(body);
+ } else if (selector.startsWith('@')) {
+ // @font-face, @keyframes and @page carry no layout we can judge.
+ } else {
+ const declarations = parseDeclarations(body);
+ order++;
+ for (const one of selector.split(',')) {
+ const sel = one.trim();
+ if (!sel || /::|:(?:hover|focus|active|visited|before|after|placeholder|checked|disabled)/i.test(sel)) continue;
+ rules.push({ selector: sel, declarations, order, specificity: specificity(sel) });
+ }
+ }
+ i = j;
+ }
+ };
+ parseBlock(text);
+ if (rules.length === 0) warnings.push('no usable CSS rules were found');
+ return { rules, warnings };
+}
+
+function specificity(selector) {
+ const s = String(selector).replace(/:root\b/g, 'html');
+ const ids = (s.match(/#[\w-]+/g) || []).length;
+ const cls = (s.match(/\.[\w-]+|\[[^\]]+\]|:[\w-]+/g) || []).length;
+ const tags = (s.match(/(^|[\s>+~])[a-zA-Z][\w-]*/g) || []).length;
+ return ids * 10000 + cls * 100 + tags;
+}
+
+function matchCompound(node, compound) {
+ if (!node || !node.tag || node.tag === '#document') return false;
+ let c = String(compound).trim();
+ if (!c) return false;
+ if (c === '*') return true;
+ c = c.replace(/:root\b/g, 'html').replace(/:(?:first|last|nth|only)-[a-z-]+(?:\([^)]*\))?/gi, '');
+ const tag = /^([a-zA-Z][\w-]*)/.exec(c);
+ if (tag && tag[1].toLowerCase() !== node.tag) return false;
+ for (const m of c.matchAll(/#([\w-]+)/g)) if (node.id !== m[1]) return false;
+ for (const m of c.matchAll(/\.([\w-]+)/g)) if (!node.classes.includes(m[1])) return false;
+ for (const m of c.matchAll(/\[([\w-]+)(?:=["']?([^"'\]]+)["']?)?\]/g)) {
+ const v = node.attrs[m[1].toLowerCase()];
+ if (v == null) return false;
+ if (m[2] != null && String(v) !== m[2]) return false;
+ }
+ return true;
+}
+
+function matches(node, selector) {
+ const tokens = String(selector).replace(/>/g, ' > ').trim().split(/\s+/);
+ let i = tokens.length - 1;
+ let n = node;
+ while (i >= 0) {
+ if (tokens[i] === '>') { i--; n = n && n.parent; if (!n) return false; continue; }
+ if (!matchCompound(n, tokens[i])) return false;
+ i--;
+ if (i < 0) return true;
+ if (tokens[i] === '>') { i--; n = n.parent; if (!n) return false; continue; }
+ const next = tokens[i];
+ let a = n.parent;
+ while (a && !matchCompound(a, next)) a = a.parent;
+ if (!a) return false;
+ n = a; i--;
+ }
+ return true;
+}
+
+function initialDisplay(tag) { return BLOCK.has(tag) ? 'block' : (INLINE.has(tag) ? 'inline' : 'block'); }
+
+function createComputed(root, rules, viewport) {
+ const rootStyle = {
+ color: [0, 0, 0, 1], 'background-color': 'transparent', 'background-image': null, 'font-size': 16, 'font-weight': 400,
+ 'line-height': 1.25, 'white-space': 'normal', 'text-align': 'left', display: 'block', position: 'static',
+ overflow: 'visible', 'overflow-x': 'visible', 'overflow-y': 'visible', width: 'auto', height: 'auto',
+ 'max-width': 'none', 'min-width': '0', margin: [0, 0, 0, 0], padding: [0, 0, 0, 0],
+ top: null, right: null, bottom: null, left: null, 'z-index': null, 'box-sizing': 'content-box',
+ opacity: 1, 'text-transform': 'none', 'letter-spacing': 0, 'word-break': 'normal', 'border-left-width': 0, 'border-right-width': 0,
+ };
+ const inherited = ['color', 'font-size', 'font-weight', 'line-height', 'white-space', 'text-align', 'text-transform', 'letter-spacing', 'word-break', 'font-family'];
+ const apply = (st, decls, parent) => {
+ for (const [k, v] of Object.entries(decls)) {
+ if (k === 'display') st.display = v;
+ else if (k === 'position') st.position = v;
+ else if (k === 'color') { const c = parseColor(v); if (c) st.color = c; }
+ else if (k === 'background-color') { const c = parseColor(v); if (c) st['background-color'] = c; }
+ else if (k === 'background-image') st['background-image'] = v;
+ else if (k === 'font-size') { const n = parseLength(v, { parent: parent['font-size'], root: 16, percentBase: parent['font-size'] }); if (n != null) st['font-size'] = Math.max(0, n); }
+ else if (k === 'font-weight') { st['font-weight'] = v === 'bold' ? 700 : v === 'normal' ? 400 : (parseInt(v, 10) || 400); }
+ else if (k === 'line-height') { const n = parseLength(v, { parent: st['font-size'], percentBase: st['font-size'] }); st['line-height'] = n == null ? v : n; }
+ else if (k === 'white-space') st['white-space'] = v;
+ else if (k === 'text-align') st['text-align'] = v;
+ else if (k === 'text-transform') st['text-transform'] = v;
+ else if (k === 'letter-spacing') { const n = parseLength(v, { parent: st['font-size'], percentBase: st['font-size'] }); if (n != null) st['letter-spacing'] = n; }
+ else if (k === 'word-break' || k === 'overflow-wrap') st['word-break'] = v;
+ else if (k === 'width' || k === 'height' || k === 'max-width' || k === 'min-width') st[k] = v;
+ else if (k === 'overflow') { st.overflow = v; st['overflow-x'] = v; st['overflow-y'] = v; }
+ else if (k === 'overflow-x') { st.overflow = v; st['overflow-x'] = v; }
+ else if (k === 'overflow-y') { st.overflow = v; st['overflow-y'] = v; }
+ else if (/^margin-(top|right|bottom|left)$/.test(k)) { const idx = { 'margin-top': 0, 'margin-right': 1, 'margin-bottom': 2, 'margin-left': 3 }[k]; const n = parseLength(v, { parent: st['font-size'], percentBase: viewport.w }); st.margin[idx] = n == null ? 0 : n; }
+ else if (/^padding-(top|right|bottom|left)$/.test(k)) { const idx = { 'padding-top': 0, 'padding-right': 1, 'padding-bottom': 2, 'padding-left': 3 }[k]; const n = parseLength(v, { parent: st['font-size'], percentBase: viewport.w }); st.padding[idx] = n == null ? 0 : n; }
+ else if (['top', 'right', 'bottom', 'left'].includes(k)) st[k] = v;
+ else if (k === 'z-index') st['z-index'] = v === 'auto' ? null : parseInt(v, 10);
+ else if (k === 'box-sizing') st['box-sizing'] = v;
+ else if (k === 'opacity') { const n = parseFloat(v); st.opacity = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 1; }
+ else if (k === 'border-left-width') st['border-left-width'] = parseLength(v) || 0;
+ else if (k === 'border-right-width') st['border-right-width'] = parseLength(v) || 0;
+ else if (k === 'hidden') st.display = 'none';
+ }
+ };
+ const walk = (node, parent, depth) => {
+ if (depth > 80) return;
+ const st = {};
+ for (const k of inherited) st[k] = parent ? parent[k] : rootStyle[k];
+ for (const k of ['color', 'background-color', 'background-image', 'font-size', 'font-weight', 'line-height', 'white-space', 'text-align', 'display', 'position', 'overflow', 'overflow-x', 'overflow-y', 'width', 'height', 'max-width', 'min-width', 'top', 'right', 'bottom', 'left', 'z-index', 'box-sizing', 'opacity', 'text-transform', 'letter-spacing', 'word-break', 'border-left-width', 'border-right-width']) {
+ st[k] = parent ? parent[k] : rootStyle[k];
+ }
+ st.margin = parent ? parent.margin.slice() : [0, 0, 0, 0];
+ st.padding = parent ? parent.padding.slice() : [0, 0, 0, 0];
+ st.color = parent ? parent.color : rootStyle.color;
+ st['background-color'] = [0, 0, 0, 0];
+ st['background-image'] = null;
+ st.display = initialDisplay(node.tag);
+ st.position = 'static'; st.overflow = 'visible'; st['overflow-x'] = 'visible'; st['overflow-y'] = 'visible';
+ st.width = 'auto'; st.height = 'auto'; st['max-width'] = 'none'; st['min-width'] = '0';
+ st.top = null; st.right = null; st.bottom = null; st.left = null; st['z-index'] = null;
+ st['box-sizing'] = 'content-box'; st.opacity = parent ? parent.opacity : 1; st['border-left-width'] = 0; st['border-right-width'] = 0;
+ st.margin = [0, 0, 0, 0]; st.padding = [0, 0, 0, 0];
+ st['font-size'] = parent ? parent['font-size'] : 16;
+ st['font-weight'] = parent ? parent['font-weight'] : 400;
+ st['line-height'] = parent ? parent['line-height'] : 1.25;
+ st['white-space'] = parent ? parent['white-space'] : 'normal';
+ st['text-align'] = parent ? parent['text-align'] : 'left';
+ st['text-transform'] = parent ? parent['text-transform'] : 'none';
+ st['letter-spacing'] = parent ? parent['letter-spacing'] : 0;
+ st['word-break'] = parent ? parent['word-break'] : 'normal';
+ if (node.attrs && (node.attrs.hidden != null)) st.display = 'none';
+ const applicable = rules.filter((r) => matches(node, r.selector)).sort((a, b) => a.specificity - b.specificity || a.order - b.order);
+ for (const r of applicable) apply(st, r.declarations, parent || rootStyle);
+ if (node.attrs && node.attrs.style) apply(st, parseDeclarations(node.attrs.style), parent || rootStyle);
+ node.style = st;
+ for (const child of node.children || []) walk(child, st, depth + 1);
+ };
+ walk(root, null, 0);
+}
+
+function selectorOf(node) {
+ if (!node || !node.tag || node.tag === '#document') return ':root';
+ const cls = (node.classes || []).slice(0, 2).map((c) => `.${c}`).join('');
+ return `${node.tag}${node.id ? `#${node.id}` : ''}${cls}`;
+}
+
+function textWidth(text, st) {
+ const fs = Number(st['font-size']) || 16;
+ const bold = Number(st['font-weight']) >= 600;
+ const spacing = Number(st['letter-spacing']) || 0;
+ let n = 0;
+ for (const ch of String(text || '')) {
+ if (ch === ' ') n += 0.28;
+ else if (/[ilI.,'`!|]/.test(ch)) n += 0.3;
+ else if (/[mwMW@#%&]/.test(ch)) n += 0.86;
+ else if (/[A-Z]/.test(ch)) n += 0.64;
+ else n += 0.53;
+ }
+ return n * fs + Math.max(0, String(text || '').length - 1) * spacing;
+}
+
+function num(st, key) {
+ const n = parseLength(st[key], { parent: st['font-size'], percentBase: st['font-size'] });
+ return n == null ? 0 : n;
+}
+
+function boxOf(node, parentBox, viewport) {
+ const st = node.style;
+ if (!st || st.display === 'none' || st.opacity === 0) return null;
+ const parentW = parentBox ? parentBox.contentW : viewport.w;
+ const parentX = parentBox ? parentBox.x : 0;
+ const parentY = parentBox ? parentBox.y : 0;
+ const pad = st.padding || [0, 0, 0, 0];
+ const mar = st.margin || [0, 0, 0, 0];
+ const border = (Number(st['border-left-width']) || 0) + (Number(st['border-right-width']) || 0);
+ const outerAvail = Math.max(0, parentW - mar[1] - mar[3]);
+ const available = Math.max(0, outerAvail - pad[1] - pad[3] - border);
+ let width;
+ const wv = String(st.width || 'auto');
+ if (wv === 'auto') width = (st.display === 'inline' || st.display === 'inline-block') ? Math.min(available, textWidth(node.text, st) + pad[1] + pad[3]) : available;
+ else if (wv.endsWith('vw')) width = parseFloat(wv) * viewport.w / 100;
+ else if (wv.endsWith('%')) width = outerAvail * parseFloat(wv) / 100;
+ else width = parseLength(wv, { parent: st['font-size'], percentBase: outerAvail }) || available;
+ const maxw = String(st['max-width'] || 'none');
+ if (maxw !== 'none' && maxw !== 'auto') {
+ const m = maxw.endsWith('%') ? outerAvail * parseFloat(maxw) / 100 : (maxw.endsWith('px') ? parseFloat(maxw) : null);
+ if (m != null) width = Math.min(width, m);
+ }
+ const minw = String(st['min-width'] || '0');
+ if (minw !== '0' && minw !== 'auto') {
+ const m = minw.endsWith('%') ? outerAvail * parseFloat(minw) / 100 : (minw.endsWith('px') ? parseFloat(minw) : null);
+ if (m != null) width = Math.max(width, m);
+ }
+ if (st['box-sizing'] === 'border-box') width = Math.max(0, width - pad[1] - pad[3] - border);
+ const fs = Number(st['font-size']) || 16;
+ const lh = typeof st['line-height'] === 'number' ? st['line-height'] : (parseFloat(st['line-height']) || 1.25);
+ const text = String(node.text || '');
+ const tw = textWidth(text, st);
+ const nowrap = /nowrap|pre/.test(String(st['white-space']));
+ const lines = Math.max(1, nowrap ? 1 : Math.ceil(tw / Math.max(1, width)));
+ const autoH = Math.max(fs * lh, lines * fs * lh) + pad[0] + pad[2];
+ let height = String(st.height || 'auto') === 'auto' ? autoH : (parseLength(st.height, { parent: fs, percentBase: (parentBox ? parentBox.height : viewport.h) }) || autoH);
+ let x = parentX + mar[3] + pad[3];
+ let y = parentY + mar[0] + pad[0];
+ const positioned = st.position === 'absolute' || st.position === 'fixed';
+ const fixed = st.position === 'fixed';
+ const cbW = fixed ? viewport.w : parentW;
+ const cbH = fixed ? viewport.h : (parentBox ? parentBox.height : viewport.h);
+ const px = (v, base) => (v == null ? null : (parseLength(v, { parent: st['font-size'], percentBase: base }) || 0));
+ const pTop = px(st.top, cbH); const pBottom = px(st.bottom, cbH);
+ const pLeft = px(st.left, cbW); const pRight = px(st.right, cbW);
+ if (positioned && String(st.height || 'auto') === 'auto' && pTop != null && pBottom != null) height = Math.max(0, cbH - pTop - pBottom - pad[0] - pad[2]);
+ if (positioned && String(st.width || 'auto') === 'auto' && pLeft != null && pRight != null) width = Math.max(0, cbW - pLeft - pRight - pad[1] - pad[3]);
+ if (positioned) {
+ const baseX = fixed ? 0 : parentX; const baseY = fixed ? 0 : parentY;
+ if (pLeft != null) x = baseX + pLeft;
+ else if (pRight != null) x = baseX + cbW - pRight - width;
+ if (pTop != null) y = baseY + pTop;
+ else if (pBottom != null) y = baseY + cbH - pBottom - height;
+ }
+ return { x, y, width, height, contentW: width, right: x + width + pad[1], bottom: y + height + pad[2], textWidth: tw, lines, nowrap };
+}
+
+function analyze(root, { viewport, maxFindings = 200 } = {}) {
+ const findings = [];
+ const add = (kind, severity, node, detail, evidence) => {
+ if (findings.length >= maxFindings) return;
+ findings.push({ kind, severity, selector: selectorOf(node), detail, evidence });
+ };
+ const boxes = [];
+ const positioned = [];
+ const walk = (node, parentBox, depth) => {
+ if (depth > 80 || findings.length >= maxFindings) return;
+ if (!node.tag || node.tag === '#document') {
+ for (const c of node.children || []) walk(c, parentBox, depth + 1);
+ return;
+ }
+ const st = node.style;
+ if (!st || st.display === 'none' || st.opacity === 0) return;
+ const box = boxOf(node, parentBox, viewport);
+ if (!box) return;
+ node.box = box;
+ boxes.push({ node, box });
+ if (st.position === 'absolute' || st.position === 'fixed') positioned.push({ node, box });
+ const text = String(node.text || '').trim();
+ if (text) {
+ // Boxes wider than the viewport, or content that does not fit the box it was given.
+ if (box.width > viewport.w + 1 || box.right > viewport.w + 1) {
+ add('overflow', 'medium', node, `the box is ${Math.round(box.width)}px wide and reaches ${Math.round(box.right)}px in a ${viewport.w}px viewport`, { width: Math.round(box.width), right: Math.round(box.right), viewport: viewport.w });
+ }
+ if (box.x < -1 || box.y < -1) {
+ add('overflow', 'high', node, `the box starts outside the viewport at ${Math.round(box.x)}, ${Math.round(box.y)}`, { x: Math.round(box.x), y: Math.round(box.y) });
+ }
+ const hiddenX = /hidden|clip|scroll|auto/.test(String(st['overflow-x'] || st.overflow));
+ if (hiddenX && box.textWidth > box.width + 2 && box.nowrap) {
+ add('cut_off', 'high', node, `nowrap text is about ${Math.round(box.textWidth)}px wide inside a ${Math.round(box.width)}px box with overflow ${st['overflow-x'] || st.overflow}`, { text: text.slice(0, 120), textWidth: Math.round(box.textWidth), boxWidth: Math.round(box.width), overflow: st['overflow-x'] || st.overflow });
+ }
+ const explicitH = String(st.height || 'auto') !== 'auto' ? parseLength(st.height, { parent: st['font-size'], percentBase: (parentBox ? parentBox.height : viewport.h) }) : null;
+ if (explicitH != null && box.lines * (Number(st['font-size']) || 16) * (typeof st['line-height'] === 'number' ? st['line-height'] : 1.25) > explicitH + 2 && /hidden|clip/.test(String(st.overflow))) {
+ add('cut_off', 'medium', node, 'the text needs more lines than the fixed height allows and overflow is hidden', { lines: box.lines, height: Math.round(explicitH), overflow: st.overflow });
+ }
+ if (!hiddenX && box.nowrap && box.textWidth > viewport.w + 1) {
+ add('overflow', 'medium', node, `nowrap text is about ${Math.round(box.textWidth)}px wide in a ${viewport.w}px viewport and can scroll the page sideways`, { text: text.slice(0, 120), textWidth: Math.round(box.textWidth), viewport: viewport.w });
+ }
+ // Contrast against the nearest painted background.
+ if (!st['background-image']) {
+ const layers = [];
+ let bgNode = node; let unknown = false; let base = [255, 255, 255, 1];
+ while (bgNode) {
+ const s2 = bgNode.style || {};
+ if (s2['background-image']) { unknown = true; break; }
+ const c = s2['background-color'];
+ if (c && c[3] > 0) {
+ layers.push(c);
+ if (c[3] >= 0.999) { base = c; break; }
+ }
+ bgNode = bgNode.parent;
+ }
+ // Composite the translucent layers from the outermost to the innermost over the opaque base.
+ let bg = base;
+ for (let i = layers.length - 1; i >= 0; i--) {
+ const l = layers[i];
+ bg = [0, 1, 2].map((k) => Math.round(l[k] * l[3] + bg[k] * (1 - l[3]))).concat(1);
+ }
+ if (!unknown) {
+ const fs = Number(st['font-size']) || 16;
+ const bold = Number(st['font-weight']) >= 700;
+ const large = fs >= 24 || (fs >= 18.66 && bold);
+ const required = large ? 3 : 4.5;
+ const ratio = contrastRatio(st.color, bg);
+ if (ratio + 0.01 < required) {
+ add('contrast', ratio < 3 ? 'high' : 'medium', node, `text contrast is ${ratio.toFixed(2)}:1 (needs ${required}:1 for ${large ? 'large' : 'body'} text)`, { ratio: Math.round(ratio * 100) / 100, required, color: colorCss(st.color), background: colorCss(bg), fontSize: fs, large });
+ }
+ }
+ }
+ }
+ // Layering faults that CSS itself states.
+ const z = st['z-index'];
+ if (z != null && (st.position === 'static' || !st.position)) {
+ add('layering', 'low', node, `z-index ${z} has no effect while position is static`, { zIndex: z, position: st.position });
+ }
+ if (z != null && z < 0) {
+ add('layering', 'medium', node, `a negative z-index (${z}) can put this box behind its parent's background`, { zIndex: z });
+ }
+ if (st.position === 'fixed' && box.width >= viewport.w * 0.9 && box.height >= viewport.h * 0.9 && (z == null || z >= 0)) {
+ add('layering', 'medium', node, 'a fixed box covers at least 90% of the viewport; check that it is intended and dismissible', { width: Math.round(box.width), height: Math.round(box.height), zIndex: z });
+ }
+ for (const c of node.children || []) walk(c, box, depth + 1);
+ };
+ walk(root, null, 0);
+
+ // Overlap between absolutely/fixed positioned boxes that are not ancestors of each other.
+ for (let i = 0; i < positioned.length && findings.length < maxFindings; i++) {
+ for (let j = i + 1; j < positioned.length && findings.length < maxFindings; j++) {
+ const A = positioned[i]; const B = positioned[j];
+ let a = A.node; let b = B.node; let related = false;
+ while (a) { if (a === B.node) { related = true; break; } a = a.parent; }
+ while (b && !related) { if (b === A.node) { related = true; break; } b = b.parent; }
+ if (related) continue;
+ const ix = Math.max(0, Math.min(A.box.right, B.box.right) - Math.max(A.box.x, B.box.x));
+ const iy = Math.max(0, Math.min(A.box.bottom, B.box.bottom) - Math.max(A.box.y, B.box.y));
+ const area = ix * iy;
+ const smaller = Math.min(Math.max(1, A.box.width * A.box.height), Math.max(1, B.box.width * B.box.height));
+ if (area > smaller * 0.25) {
+ add('overlap', 'medium', A.node, `this positioned box overlaps ${selectorOf(B.node)} by ${Math.round(area / smaller * 100)}% of the smaller box`, { with: selectorOf(B.node), overlapPct: Math.round(area / smaller * 100) });
+ }
+ }
+ }
+ return findings;
+}
+
+function summarize(findings, { engine, viewport, source, url = null, warnings = [] } = {}) {
+ const counts = {};
+ const severity = { high: 0, medium: 0, low: 0 };
+ for (const f of findings) { counts[f.kind] = (counts[f.kind] || 0) + 1; severity[f.severity] = (severity[f.severity] || 0) + 1; }
+ return {
+ ok: findings.length === 0,
+ engine, source, ...(url ? { url } : {}), viewport,
+ counts, severity, findings: findings.length,
+ summary: findings.length
+ ? `${findings.length} UI ${findings.length === 1 ? 'fault' : 'faults'} found (${Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(', ')})`
+ : 'No overflow, cut-off text, low contrast, overlap or layering fault was found by this pass.',
+ warnings: [...new Set(warnings)].slice(0, 8),
+ };
+}
+
+function checkHtml(html, { viewport = { w: 1280, h: 800 }, maxFindings = 200, source = 'html', url = null } = {}) {
+ const parsed = parseHtml(html);
+ const { rules, warnings } = parseRules(parsed.styles.join('\n'), viewport);
+ createComputed(parsed.root, rules, viewport);
+ const findings = analyze(parsed.root, { viewport, maxFindings });
+ return { ...summarize(findings, { engine: 'static-html', viewport, source, url, warnings: [...parsed.warnings, ...warnings] }), findings };
+}
+
+async function fetchHtml(rawUrl, { config, fetchImpl = globalThis.fetch }) {
+ const maxBytes = (config && config.ui && config.ui.maxHtmlBytes) || 1024 * 1024;
+ const allowPrivate = Boolean(config && config.ui && config.ui.allowPrivate);
+ const timeoutMs = (config && config.ui && config.ui.timeoutMs) || 10000;
+ let url = await assertPublicUrl(rawUrl, { allowPrivate });
+ for (let hop = 0; hop < 4; hop++) {
+ let res;
+ try { res = await fetchImpl(url.toString(), { redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), headers: { Accept: 'text/html,application/xhtml+xml', 'User-Agent': 'OpenVibe.Vision/0.1 UI check' } }); }
+ catch (err) { const e = new Error(err && err.name === 'TimeoutError' ? 'the page did not answer in time' : 'the page could not be reached'); e.code = 'ui.unreachable'; throw e; }
+ if (res.status >= 300 && res.status < 400) {
+ const loc = res.headers.get('location');
+ if (!loc) { const e = new Error('the page redirected without a location'); e.code = 'ui.unreachable'; throw e; }
+ url = await assertPublicUrl(new URL(loc, url).toString(), { allowPrivate });
+ continue;
+ }
+ if (!res.ok) { const e = new Error(`the page answered ${res.status}`); e.code = 'ui.unreachable'; throw e; }
+ const type = String(res.headers.get('content-type') || '').toLowerCase();
+ const len = Number(res.headers.get('content-length'));
+ if (Number.isFinite(len) && len > maxBytes) { const e = new Error('the page is larger than the UI check limit'); e.code = 'ui.too_large'; throw e; }
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (buf.length > maxBytes) { const e = new Error('the page is larger than the UI check limit'); e.code = 'ui.too_large'; throw e; }
+ const body = buf.toString('utf8');
+ if (!/html|xhtml|text\/plain/.test(type) && !/^\s*<!doctype|^\s*<html/i.test(body)) { const e = new Error(`expected an HTML page, got ${type || 'an unknown type'}`); e.code = 'ui.not_html'; throw e; }
+ return { html: body, url: url.toString() };
+ }
+ const e = new Error('too many redirects for that page'); e.code = 'ui.unreachable'; throw e;
+}
+
+/** Screenshot pass: OCR boxes at an edge, and the contrast inside each word box. */
+async function checkScreenshot(buffer, { viewport = { w: 1280, h: 800 }, ocr, maxFindings = 200 } = {}) {
+ const meta = await sharp(buffer, { failOn: 'none' }).metadata();
+ const width = Number(meta.width) || viewport.w;
+ const height = Number(meta.height) || viewport.h;
+ const findings = [];
+ const warnings = ['a screenshot cannot show CSS layering or the DOM; the static-html pass does that'];
+ if (ocr) {
+ const read = await ocr.recognize(buffer).catch(() => null);
+ if (read && read.ok) {
+ const raw = await sharp(buffer, { failOn: 'none' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
+ const { data, info } = raw;
+ for (const word of read.words || []) {
+ if (findings.length >= maxFindings) break;
+ const right = word.x + word.w; const bottom = word.y + word.h;
+ if (right >= width - 2 || bottom >= height - 2 || word.x <= 1 || word.y <= 1) {
+ findings.push({ kind: 'cut_off', severity: right >= width - 2 ? 'high' : 'medium', selector: `text:"${word.text.slice(0, 40)}"`, detail: `the word "${word.text.slice(0, 60)}" sits at the edge of the ${width}×${height} screenshot and may be cut off`, evidence: { x: word.x, y: word.y, w: word.w, h: word.h, width, height } });
+ }
+ if (word.conf >= 40 && word.w > 3 && word.h > 3) {
+ let min = 1; let max = 0;
+ for (let y = Math.max(0, word.y); y < Math.min(info.height, word.y + word.h); y += Math.max(1, Math.floor(word.h / 12))) {
+ for (let x = Math.max(0, word.x); x < Math.min(info.width, word.x + word.w); x += Math.max(1, Math.floor(word.w / 16))) {
+ const i = (y * info.width + x) * info.channels;
+ const lum = (0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2]) / 255;
+ if (lum < min) min = lum;
+ if (lum > max) max = lum;
+ }
+ }
+ const ratio = (Math.max(min, max) + 0.05) / (Math.min(min, max) + 0.05);
+ const fs = word.h;
+ const required = fs >= 24 ? 3 : 4.5;
+ if (max - min > 0.05 && ratio < required) {
+ findings.push({ kind: 'contrast', severity: ratio < 3 ? 'high' : 'medium', selector: `text:"${word.text.slice(0, 40)}"`, detail: `the text "${word.text.slice(0, 60)}" measures about ${ratio.toFixed(2)}:1 against its background`, evidence: { ratio: Math.round(ratio * 100) / 100, required, x: word.x, y: word.y, w: word.w, h: word.h } });
+ }
+ }
+ }
+ if (!(read.words || []).length) warnings.push('the screenshot contained no readable text for the OCR boxes; only the image size was checked');
+ } else if (read && !read.ok) {
+ warnings.push(`the OCR pass did not run: ${read.detail || read.code}`);
+ }
+ } else {
+ warnings.push('no local OCR engine was available for the screenshot text boxes');
+ }
+ const out = summarize(findings, { engine: 'screenshot', viewport: { w: width, h: height }, source: 'screenshot', warnings });
+ return { ...out, findings };
+}
+
+async function check(opts = {}) {
+ const viewport = { w: Math.max(160, Math.min(10000, Number(opts.viewport && opts.viewport.w) || 1280)), h: Math.max(160, Math.min(10000, Number(opts.viewport && opts.viewport.h) || 800)) };
+ const maxFindings = Math.min(500, Math.max(10, Number(opts.maxFindings) || 200));
+ if (opts.imageBuffer) return checkScreenshot(opts.imageBuffer, { viewport, ocr: opts.ocr, maxFindings });
+ if (opts.html) return checkHtml(opts.html, { viewport, maxFindings, source: 'html' });
+ if (opts.url) {
+ const fetched = await fetchHtml(opts.url, { config: opts.config, fetchImpl: opts.fetchImpl });
+ return checkHtml(fetched.html, { viewport, maxFindings, source: 'url', url: fetched.url });
+ }
+ const e = new Error('send html, a URL or a screenshot image'); e.code = 'ui.input_required'; throw e;
+}
+
+module.exports = { check, checkHtml, checkScreenshot, fetchHtml, parseHtml, parseRules, analyze, contrastRatio, parseColor, textWidth };
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * ADR-033: Vision's part of an account export (answers, compare samples, UI checks) and of a deletion, applied
+ * through /internal/events with a stand-in Network. A deletion erases the person's rows and leaves other people's.
+ * A redelivery changes nothing; a bad signature and a proxied request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = ids.newId('user');
+const B = ids.newId('user');
+const EXP = `exp_${ids.ulid()}`;
+const DEL = `del_${ids.ulid()}`;
+const SECRET = `fixture_${'account'.repeat(6)}`;
+const WRONG = `fixture_${'mismatch'.repeat(6)}`;
+const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+const IMAGE = { source: 'upload', sourceUrl: null, sha256: 'a'.repeat(64), mime: 'image/png', bytes: 100 };
+
+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_vision', 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: 'vision', clientSecret: 'vision-secret' });
+ const t = await boot({ env: { VISION_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const aAnswer = await t.ctx.store.saveAnswer({ subject: A, requester: `user:${A}`, task: 'ask', question: 'What is this?', image: IMAGE, text: 'A answer', answer: { engine: 'ai', model: 'm' }, model: 'm', provider: 'mock', route: 'default.chat', costUsd: 0.0004, latencyMs: 10 });
+ const bAnswer = await t.ctx.store.saveAnswer({ subject: B, requester: `user:${B}`, task: 'ocr', image: IMAGE, text: 'B answer', answer: { engine: 'local' }, model: 'tesseract', provider: 'local', route: 'local.ocr' });
+ const aCheck = await t.ctx.store.saveUiCheck({ subject: A, requester: `user:${A}`, source: 'html', viewport: { w: 390, h: 844 }, engine: 'static-html', findings: [{ kind: 'overflow' }], summary: { summary: 'one fault' } });
+ const bCheck = await t.ctx.store.saveUiCheck({ subject: B, requester: `user:${B}`, source: 'url', url: 'https://openvibe.vision/', viewport: { w: 1280, h: 800 }, engine: 'static-html', findings: [], summary: {} });
+ const aCompare = await t.ctx.store.saveCompare({ subject: A, requester: `user:${A}`, task: 'ask', image: IMAGE, question: 'compare', samples: [{ engine: 'local', model: 'tesseract', provider: 'local', route: 'local.ocr', ok: true, text: 'A text', costUsd: 0, ms: 4 }] });
+
+ await check('an export carries only A\'s answers, compare samples and UI checks', 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_vision');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['answers.json', 'compare.json', 'ui-checks.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes(aAnswer.id));
+ assert.ok(all.includes(aCheck.id));
+ assert.ok(all.includes(aCompare.answer.id));
+ assert.ok(!all.includes(bAnswer.id));
+ assert.ok(!all.includes(bCheck.id));
+ assert.ok(!all.includes('B answer'));
+ const answers = part.body.files.find((f) => f.name === 'answers.json').content;
+ assert.ok(answers.length >= 2);
+ assert.ok(answers.every((x) => x.subject === A));
+ });
+
+ await check('a deletion erases A\'s rows and confirms with counts; B and no one else is touched', 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;
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_compare_samples WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_answers WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM vision_ui_checks WHERE subject = $1', [B])), 1);
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.ok(confirmation.body.erased.vision_answers >= 1);
+ assert.ok(confirmation.body.erased.vision_ui_checks >= 1);
+ assert.ok(confirmation.body.erased.vision_compare_samples >= 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');
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const body = bodyOf(exportEvent);
+ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, WRONG) } })).status, 401);
+ assert.strictEqual((await t.get('/internal/events', { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(body, SECRET) } })).status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * The JSON API: local OCR first, an AI answer with provenance and noLooks, a side-by-side compare, the UI check,
+ * service tokens per capability, public model hints and benchmarks, and the problem+json refusals.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+ const other = t.network.addUser('sam');
+ const png = await t.textImage('TOTAL 5.50');
+
+ try {
+ await check('POST /ocr answers locally with text, confidence, zero cost and no AI run', async () => {
+ const r = await t.get('/api/v1/ocr', { multipart: { image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.strictEqual(b.task, 'ocr');
+ assert.match(b.text, /TOTAL 5\.50/);
+ assert.strictEqual(b.answer.engine, 'local');
+ assert.strictEqual(b.model, 'tesseract');
+ assert.strictEqual(Number(b.cost_usd), 0);
+ assert.ok(b.answer.confidence > 0);
+ assert.ok(Array.isArray(b.answer.words));
+ assert.strictEqual(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);
+ });
+
+ await check('POST /ask: a read question is answered by local OCR, a describe question by AI', async () => {
+ const read = await t.get('/api/v1/ask', { multipart: { fields: { question: 'What does the receipt say?' }, image: png } });
+ assert.strictEqual(read.status, 201, read.text);
+ assert.strictEqual(read.json().answer.engine, 'local');
+ assert.match(read.json().answer.text, /TOTAL 5\.50/);
+ assert.strictEqual(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);
+
+ const described = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
+ assert.strictEqual(described.status, 201, described.text);
+ const a = described.json().answer;
+ assert.strictEqual(a.answer.engine, 'ai');
+ assert.strictEqual(a.provider, 'mock');
+ assert.strictEqual(a.model, 'mock-vision-one');
+ assert.match(a.reason, /OpenVibe\.AI routed/);
+ assert.match(a.text, /red bicycle/);
+ assert.ok(t.aiServer.requests.some((x) => x.path === '/v1/chat/completions' && JSON.stringify(x.body).includes('image_url')));
+ });
+
+ await check('POST /describe is the plan\'s operation name for the same ask, and a data URL works', async () => {
+ const r = await t.get('/api/v1/describe', { as: user, json: { question: 'What is this?', image: `data:image/png;base64,${png.toString('base64')}` } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().answer.task, 'describe');
+ assert.strictEqual(r.json().answer.answer.engine, 'ai');
+ });
+
+ await check('an anonymous describe is refused but an anonymous read question is local', async () => {
+ const no = await t.get('/api/v1/ask', { multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
+ assert.strictEqual(no.status, 401);
+ assert.strictEqual(no.json().code, 'token.required');
+ const ok = await t.get('/api/v1/ask', { multipart: { fields: { question: 'What does it say?' }, image: png } });
+ assert.strictEqual(ok.status, 201);
+ assert.strictEqual(ok.json().answer.engine, 'local');
+ });
+
+ await check('the AI answer is passed through noLooks even when the model names looks', async () => {
+ t.aiServer.setText('A shirtless man in a kitchen laughs.');
+ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What happens?' }, image: png } });
+ assert.strictEqual(r.status, 201);
+ assert.match(r.json().answer.text, /without describing anyone's appearance/);
+ assert.ok(!/shirtless/.test(r.json().answer.text));
+ const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();
+ assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
+ });
+
+ await check('POST /compare runs local and AI, reports requested vs answered model, cost and latency', async () => {
+ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Read and describe this image.', models: ['mock:one', 'mock:two'] }, image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.strictEqual(b.compare.length, 3);
+ assert.strictEqual(b.compare[0].engine, 'local');
+ assert.strictEqual(b.compare[0].cost_usd, 0);
+ assert.ok(b.compare.every((x) => Number.isFinite(x.latency_ms)));
+ assert.strictEqual(b.compare[1].requested_model, 'mock:one');
+ assert.strictEqual(b.compare[1].model, 'mock:one');
+ assert.strictEqual(b.answer.engine, 'compare');
+ assert.ok(b.answer.answer.samples.length === 3);
+ });
+
+ await check('the UI check finds a planted overflow and low contrast as JSON', async () => {
+ const html = '<style>body{background:#fff;color:#999}.wide{width:1600px}</style><div class="wide">wide</div><p>low contrast</p>';
+ const r = await t.get('/api/v1/ui-check', { json: { html, viewport: { w: 390, h: 844 } } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json().check;
+ assert.strictEqual(c.engine, 'static-html');
+ assert.strictEqual(c.viewport.w, 390);
+ assert.ok(c.findings.some((f) => f.kind === 'overflow'));
+ assert.ok(c.findings.some((f) => f.kind === 'contrast'));
+ assert.ok(c.findings.every((f) => f.kind && f.severity && f.selector && f.detail));
+ });
+
+ await check('a service token with vision.ui.check can call the UI check; one without it is 403', async () => {
+ const good = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'] });
+ const ok = await t.get('/api/v1/ui-check', { bearer: good, json: { html: '<div style="width:900px">x</div>', viewport: { w: 390, h: 844 } } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ const bad = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ask'] });
+ const no = await t.get('/api/v1/ui-check', { bearer: bad, json: { html: '<p>x</p>' } });
+ assert.strictEqual(no.status, 403);
+ assert.strictEqual(no.json().code, 'capability.denied');
+ });
+
+ await check('a service token with vision.ask can ask; the answer has no subject', async () => {
+ const tok = t.network.serviceToken({ sub: 'svc:today', cap: ['vision.ask'] });
+ const r = await t.get('/api/v1/ask', { bearer: tok, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().answer.answer.engine, 'ai');
+ const row = await t.ctx.s.db.maybe('SELECT subject, requester FROM vision_answers WHERE id = $1', [r.json().answer.id]);
+ assert.strictEqual(row.subject, null);
+ assert.strictEqual(row.requester, 'service:today');
+ });
+
+ await check('one saved answer is visible to its owner, not to another person, and to a service with the capability', async () => {
+ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
+ const id = r.json().answer.id;
+ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { as: user })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { as: other })).status, 404);
+ const tok = t.network.serviceToken({ sub: 'svc:today', cap: ['vision.ask'] });
+ assert.strictEqual((await t.get(`/api/v1/answers/${id}`, { bearer: tok })).status, 200);
+ });
+
+ await check('GET /models and /benchmarks expose hints and aggregates, never a subject', async () => {
+ const m = await t.get('/api/v1/models');
+ assert.strictEqual(m.status, 200);
+ assert.strictEqual(m.json().ai, true);
+ assert.ok(m.json().offered.some((x) => x.model === 'mock-vision-one'));
+ assert.ok(m.json().local.includes('tesseract'));
+ await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Describe it.', models: ['mock:one'] }, image: png } });
+ const b = await t.get('/api/v1/benchmarks');
+ assert.strictEqual(b.status, 200);
+ assert.ok(b.json().benchmarks.some((x) => x.engine === 'local' && x.model === 'tesseract'));
+ assert.ok(!b.text.includes(user.subject));
+ });
+
+ await check('an image URL is fetched through the public-address guard; a bad image is refused', async () => {
+ const r = await t.get('/api/v1/ocr', { json: { image_url: `${t.images.url}/image.png` } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json().text, /HELLO VISION/);
+ const bad = await t.get('/api/v1/ocr', { json: { image: 'data:image/png;base64,AAAA' } });
+ assert.strictEqual(bad.status, 400);
+ assert.match(bad.json().code, /vision\.image/);
+ });
+
+ await check('problem+json is used for a malformed body, an invalid task and an unknown route', async () => {
+ const bad = await t.get('/api/v1/ask', { as: user, 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 task = await t.get('/api/v1/ask', { as: user, json: { task: 'see', image: `data:image/png;base64,${png.toString('base64')}` } });
+ assert.strictEqual(task.status, 400);
+ assert.strictEqual(task.json().code, 'vision.invalid_task');
+ const nope = await t.get('/api/v1/nope');
+ assert.strictEqual(nope.status, 404);
+ assert.strictEqual(nope.json().code, 'route.not_found');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
+'use strict';
+/**
+ * Compare: the local reader is free, each AI hint is a separate run, the model/provider reported are AI's
+ * provenance (not the requested hint when AI answers differently), and /benchmarks aggregates without subjects.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('ana');
+ const png = await t.textImage('TOTAL 5.50');
+ try {
+ await check('a compare with two hints returns three engines in order with measured cost and latency', async () => {
+ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Read and describe this.', models: ['mock:one', 'mock:two'] }, image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.deepStrictEqual(b.compare.map((x) => x.engine), ['local', 'ai', 'ai']);
+ assert.strictEqual(b.compare[0].cost_usd, 0);
+ assert.strictEqual(b.compare[1].requested_model, 'mock:one');
+ assert.strictEqual(b.compare[2].requested_model, 'mock:two');
+ assert.ok(b.compare[1].latency_ms >= 0);
+ assert.ok(b.compare[1].cost_usd > 0);
+ assert.strictEqual(b.answer.engine, 'compare');
+ assert.strictEqual(b.answer.answer.samples.length, 3);
+ });
+
+ await check('without hints, the offered route models are read and used as hints', async () => {
+ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { question: 'Describe it.' }, image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.ok(b.compare.length >= 2);
+ assert.ok(b.compare.some((x) => x.requested_model === 'mock:mock-vision-one'));
+ assert.ok(b.compare.some((x) => x.requested_model === 'mock:mock-vision-two'));
+ assert.ok(t.aiServer.requests.some((x) => x.path === '/api/v1/routes'));
+ });
+
+ await check('a failing AI run is recorded as a failed sample; the local answer still returns', async () => {
+ t.aiServer.setFail(true);
+ const r = await t.get('/api/v1/compare', { as: user, multipart: { fields: { models: ['mock:one'] }, image: png } });
+ t.aiServer.setFail(false);
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.strictEqual(b.compare[0].ok, true);
+ assert.strictEqual(b.compare[1].ok, false);
+ assert.ok(b.compare[1].error_code);
+ assert.match(b.answer.text, /TOTAL/);
+ });
+
+ await check('benchmarks aggregate by engine/model and never expose a subject or answer text', async () => {
+ const b = await t.get('/api/v1/benchmarks?task=ask');
+ assert.strictEqual(b.status, 200);
+ assert.ok(b.json().benchmarks.length >= 2);
+ assert.ok(b.json().benchmarks.every((x) => x.samples >= 1 && x.avg_latency_ms >= 0 && x.avg_cost_usd >= 0));
+ assert.ok(!b.text.includes(user.subject));
+ assert.ok(!b.text.includes('TOTAL 5.50'));
+ assert.strictEqual((await t.get('/api/v1/benchmarks?task=nope')).status, 400);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.VISION_RATE_LIMIT_PER_MIN = process.env.VISION_RATE_LIMIT_PER_MIN || '100000';
+process.env.VISION_API_RATE_LIMIT_PER_MIN = process.env.VISION_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.VISION_LIMITS_MINUTE = process.env.VISION_LIMITS_MINUTE || '100000';
+process.env.VISION_LIMITS_HOUR = process.env.VISION_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Vision on a temp database with a mock Network (test/helpers/mocks.js), a mock OpenVibe.AI and a
+ * local image fixture, 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, multipart, headers, method, cookie, origin })
+ * t.signIn(user) → cookie header value (vision_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork, startAi } = require('./mocks');
+const { startImageServer, startPageServer, textImage } = require('./images');
+
+const captured = [];
+for (const m of ['log', 'info', 'warn', 'error']) {
+ const orig = console[m].bind(console);
+ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const aiServer = await startAi(opts.ai || {});
+ const images = await startImageServer(opts.images || {});
+ const pages = await startPageServer(opts.pages || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.vision', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'vision', OV_OAUTH_CLIENT_SECRET: 'vision-secret', COOKIE_SECURE: 'false',
+ VISION_DRILL: '1', VISION_ALLOW_PRIVATE_URLS: '1',
+ VISION_EVENTS_URL: network.url,
+ OV_AI_URL: aiServer.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, ocr: opts.ocr, ai: opts.aiClient });
+ 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) => `vision_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'; }
+ if (o.multipart) {
+ const fd = new FormData();
+ for (const [k, v] of Object.entries(o.multipart.fields || {})) {
+ if (Array.isArray(v)) for (const x of v) fd.append(k, String(x));
+ else if (v != null) fd.append(k, String(v));
+ }
+ if (o.multipart.image) fd.append('image', new Blob([o.multipart.image], { type: o.multipart.imageType || 'image/png' }), o.multipart.filename || 'image.png');
+ body = fd;
+ }
+ 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, aiServer, images, pages, config, ctx: built.ctx, get, signIn, dbDump, textImage,
+ 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();
+ await aiServer.close();
+ await images.close();
+ await pages.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, 10).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * VISION_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.VISION_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'vision', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+/** A tiny public-image fixture and a rendered text image for the local OCR and UI checks. */
+const http = require('http');
+const sharp = require('sharp');
+
+async function textImage(text = 'HELLO VISION', { width = 640, height = 220, background = '#ffffff', color = '#000000' } = {}) {
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"><rect width="100%" height="100%" fill="${background}"/><text x="24" y="${Math.round(height * 0.62)}" font-family="DejaVu Sans" font-size="${Math.round(height * 0.38)}" fill="${color}">${String(text).replace(/[<>&]/g, '')}</text></svg>`;
+ return await sharp(Buffer.from(svg)).png().toBuffer();
+}
+
+async function startImageServer({ png = null } = {}) {
+ const body = png || await textImage('HELLO VISION');
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ if (req.url === '/image.png') { res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': body.length }); return res.end(body); }
+ if (req.url === '/redirect') { res.writeHead(302, { Location: '/image.png' }); return res.end(); }
+ res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found');
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return {
+ url: `http://127.0.0.1:${server.address().port}`,
+ png: body,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ };
+}
+
+async function startPageServer({ html = '<!doctype html><html><body><p>ok</p></body></html>' } = {}) {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ if (req.url === '/page') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html); }
+ if (req.url === '/redirect') { res.writeHead(302, { Location: '/page' }); return res.end(); }
+ res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found');
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return {
+ url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ };
+}
+
+module.exports = { textImage, startImageServer, startPageServer };
new file mode 100644
new file mode 100644
@@ -0,0 +1,192 @@
@@ -0,0 +1,192 @@
+'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 `vision`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Vision, 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 !== 'vision' || body.client_secret !== 'vision-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:vision', 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,
+ serviceToken({ sub = 'svc:design', cap = ['vision.ui.check'], audience = 'openvibe.vision', ttl = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub, actor_type: 'service', aud: [audience], cap, iat: now, exp: now + ttl, jti: crypto.randomUUID() }, privatePem);
+ },
+ 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 };
+
+
+/**
+ * An in-process OpenVibe.AI stand-in: the OpenAI-compatible endpoint and the route list. It does not verify the
+ * token cryptographically; it records the Authorization header's shape and the request body, and answers with a
+ * provider/model/cost so the tests exercise Vision's provenance path.
+ */
+async function startAi({ defaultModel = 'mock-vision-one', provider = 'mock', cost = 0.0004, fail = false, text = null } = {}) {
+ const state = { requests: [], fail: false, text };
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ const body = raw.length ? JSON.parse(raw.toString('utf8') || '{}') : {};
+ state.requests.push({ method: req.method, path: url.pathname, auth: req.headers.authorization || '', body });
+ if (!String(req.headers.authorization || '').startsWith('Bearer ')) return json(401, { error: { message: 'missing token', type: 'authentication_error', code: 'token.required' } });
+ if (state.fail) return json(503, { error: { message: 'AI is down for this test', type: 'api_error', code: 'ai.unavailable' } });
+ if (url.pathname === '/v1/chat/completions' && req.method === 'POST') {
+ const asked = String(body.model || defaultModel);
+ const hasImage = JSON.stringify(body.messages || []).includes('image_url');
+ const prompt = JSON.stringify(body.messages || []);
+ let out = state.text;
+ if (out == null) {
+ if (!hasImage) out = 'No image was attached.';
+ else if (/Read every visible piece of text|Read all visible text/.test(prompt)) out = 'HELLO VISION\nTOTAL 5.50';
+ else if (/How many|count/i.test(prompt)) out = '3 objects.';
+ else out = 'A red bicycle leans against a wall beside a window.';
+ }
+ return json(200, {
+ id: `chatcmpl-${crypto.randomBytes(6).toString('hex')}`,
+ object: 'chat.completion', model: asked,
+ choices: [{ index: 0, message: { role: 'assistant', content: out }, finish_reason: 'stop' }],
+ usage: { prompt_tokens: 120, completion_tokens: 24, total_tokens: 144 },
+ x_openvibe: { run_id: `run_${crypto.randomBytes(8).toString('hex')}`, provider, fallback_used: false, cost_usd: cost, synthetic: false },
+ });
+ }
+ if (url.pathname === '/api/v1/routes' && req.method === 'GET') {
+ return json(200, { routes: [
+ { key: 'default.chat', capability: 'chat', primary: { provider, model: 'mock-vision-one' }, fallbacks: [{ provider, model: 'mock-vision-two' }] },
+ ] });
+ }
+ if (url.pathname === '/api/v1/runs/fail' && req.method === 'GET') return json(200, { run: { status: 'failed' } });
+ return json(404, { error: { message: 'not found', code: 'route.not_found' } });
+ });
+ return {
+ url: srv.url, requests: state.requests,
+ setFail(v) { state.fail = Boolean(v); },
+ setText(v) { state.text = v == null ? null : String(v); },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, startAi, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * The expand migration applies in one pass and creates exactly the Vision tables, plus the SDK's account receipts
+ * and telemetry. The answer constraints refuse a bad id, a task outside v1 and an unknown engine.
+ */
+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 Vision 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 ['vision_answers', 'vision_compare_samples', 'vision_ui_checks', 'account_data_events', 'telemetry_samples']) {
+ assert.ok(names.includes(table), `missing ${table}`);
+ }
+ assert.ok(!names.includes('vision_outbox'), 'v1 has no public records and no Search outbox');
+ });
+
+ await check('an answer cannot claim a bad id, an unknown task or a bad image hash', async () => {
+ const db = t.ctx.s.db;
+ const sha = 'a'.repeat(64);
+ await assert.rejects(db.exec(
+ "INSERT INTO vision_answers (id, requester, task, source, image_sha256, image_mime, image_bytes) VALUES ('bad', 'user:usr_x', 'see', 'upload', $1, 'image/png', 10)",
+ [sha],
+ ), /vision_answers_id|vision_answers_task/);
+ await assert.rejects(db.exec(
+ "INSERT INTO vision_answers (id, requester, task, source, image_sha256, image_mime, image_bytes) VALUES ($1, 'user:usr_x', 'ask', 'upload', 'nope', 'image/png', 10)",
+ [`vis_${ids.ulid()}`],
+ ), /vision_answers_sha/);
+ });
+
+ await check('a compare sample only names local or ai and a v1 task', async () => {
+ const db = t.ctx.s.db;
+ const id = `vcm_${ids.ulid()}`;
+ await assert.rejects(db.exec("INSERT INTO vision_compare_samples (id, task, engine) VALUES ($1, 'ask', 'cloud')", [id]), /vision_compare_samples_engine/);
+ await assert.rejects(db.exec("INSERT INTO vision_compare_samples (id, task, engine) VALUES ($1, 'see', 'ai')", [id]), /vision_compare_samples_task/);
+ });
+
+ await check('a UI check keeps findings as jsonb and a valid viewport', async () => {
+ const db = t.ctx.s.db;
+ const id = `vuc_${ids.ulid()}`;
+ await db.exec("INSERT INTO vision_ui_checks (id, requester, source, engine, findings, summary) VALUES ($1, 'anonymous', 'html', 'static-html', '[{\"kind\":\"overflow\"}]'::jsonb, '{}'::jsonb)", [id]);
+ const row = await db.maybe('SELECT findings FROM vision_ui_checks WHERE id = $1', [id]);
+ assert.strictEqual(row.findings[0].kind, 'overflow');
+ await assert.rejects(db.exec("INSERT INTO vision_ui_checks (id, requester, source, engine, viewport_w) VALUES ($1, 'anonymous', 'html', 'static-html', 10)", [`vuc_${ids.ulid()}`]), /vision_ui_checks_viewport/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+/**
+ * No looks in public AI text: a summary that names looks becomes the title; an answer that names looks is replaced,
+ * never repaired word by word; a normal answer survives; and the prompt carries the rule.
+ */
+const assert = require('assert');
+const { cleanAnswer, cleanSummary, hasLooks, SAFE_ANSWER } = require('../server/text/no-looks');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('an answer that names looks is replaced by the neutral sentence', async () => {
+ assert.strictEqual(cleanAnswer('A shirtless man in a kitchen laughs.'), SAFE_ANSWER);
+ assert.ok(hasLooks('A woman with long blonde hair smiles.'));
+ assert.strictEqual(cleanAnswer('A red bicycle leans on a wall.'), 'A red bicycle leans on a wall.');
+ assert.strictEqual(cleanSummary('Robot is offline', 'A shirtless man reacts.'), 'Robot is offline');
+ });
+
+ const t = await boot();
+ try {
+ await check('the AI prompt forbids looks and the stored answer is cleaned', async () => {
+ t.aiServer.setText('A tall woman in a red dress holds a sign.');
+ const user = t.network.addUser('kim');
+ const png = await t.textImage('HELLO');
+ const r = await t.get('/api/v1/ask', { as: user, multipart: { fields: { task: 'describe', question: 'What is happening?' }, image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json().answer.text, SAFE_ANSWER);
+ const stored = await t.ctx.s.db.value('SELECT text FROM vision_answers WHERE id = $1', [r.json().answer.id]);
+ assert.strictEqual(stored, SAFE_ANSWER);
+ const sent = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').pop();
+ assert.match(JSON.stringify(sent.body), /Never describe a person's body/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+'use strict';
+/**
+ * The local reader: a rendered text image is read with confidence and boxes, a read question stops there, and the
+ * AI fallback runs only when the local reader finds nothing. A missing engine is an honest 503.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { parseTsv } = require('../server/vision/ocr');
+
+(async () => {
+ await check('parseTsv keeps reading order, words and mean confidence', async () => {
+ const tsv = [
+ 'level\tpage_num\tblock_num\tpar_num\tline_num\tword_num\tleft\ttop\twidth\theight\tconf\ttext',
+ '5\t1\t1\t1\t1\t1\t10\t10\t40\t20\t90\tHELLO',
+ '5\t1\t1\t1\t1\t2\t60\t10\t70\t20\t80\tVISION',
+ '5\t1\t1\t1\t2\t1\t10\t40\t50\t20\t70\tTOTAL',
+ '5\t1\t1\t1\t2\t2\t70\t40\t30\t20\t60\t5.50',
+ ].join('\n');
+ const out = parseTsv(tsv);
+ assert.strictEqual(out.text, 'HELLO VISION\nTOTAL 5.50');
+ assert.strictEqual(out.words.length, 4);
+ assert.strictEqual(out.words[0].text, 'HELLO');
+ assert.ok(out.confidence > 70 && out.confidence < 80);
+ });
+
+ const t = await boot();
+ try {
+ await check('the local reader reads a rendered image with no AI call and zero cost', async () => {
+ const png = await t.textImage('HELLO VISION');
+ const r = await t.get('/api/v1/ocr', { multipart: { image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.match(b.text, /HELLO VISION/);
+ assert.strictEqual(b.answer.engine, 'local');
+ assert.strictEqual(b.model, 'tesseract');
+ assert.strictEqual(Number(b.cost_usd), 0);
+ assert.ok(b.answer.confidence >= 0);
+ assert.ok(b.answer.words.some((w) => w.text === 'HELLO'));
+ });
+
+ await check('a blank image with fallback=1 asks AI only after the local pass found nothing', async () => {
+ const blank = await t.textImage('');
+ const before = t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length;
+ const r = await t.get('/api/v1/ocr', { as: t.network.addUser('reader'), multipart: { fields: { fallback: '1' }, image: blank } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ assert.strictEqual(b.answer.engine, 'ai');
+ assert.match(b.text, /HELLO VISION/);
+ assert.ok(t.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length > before);
+ });
+ } finally {
+ await t.close();
+ }
+
+ const missing = await boot({ ocr: { recognize: async () => ({ ok: false, code: 'vision.ocr_unavailable', detail: 'the local OCR engine (tesseract) is not installed on this host' }), available: async () => null } });
+ try {
+ await check('a missing local engine answers 503, not a silent cloud call', async () => {
+ const r = await missing.get('/api/v1/ocr', { json: { image: `data:image/png;base64,${(await missing.textImage('X')).toString('base64')}` } });
+ assert.strictEqual(r.status, 503);
+ assert.strictEqual(r.json().code, 'vision.ocr_unavailable');
+ assert.strictEqual(missing.aiServer.requests.filter((x) => x.path === '/v1/chat/completions').length, 0);
+ });
+ } finally {
+ await missing.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+'use strict';
+/**
+ * Server-rendered pages: the home, ask, OCR, compare, the UI check, one saved answer and one saved UI check, the
+ * developers page, 404, cache headers, and the content-hashed assets. Public pages are public, max-age=60; a page
+ * drawn for a signed-in person is private, no-store.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { assetVersion } = require('../server/render/layout');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+function jsonLd(html) {
+ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+}
+const PLANTED = '<style>body{background:#fff;color:#999}.wide{width:1600px}</style><div class="wide">wide</div><p>low contrast</p>';
+
+(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' });
+ const png = await t.textImage('HELLO VISION');
+ try {
+ await check('home: semantic, the room, the try section, public cache with Vary: Cookie', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ for (const marker of ['id="vi-home"', 'id="vi-try"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', '/ask', '/ocr', '/compare', '/ui-check']) assert.ok(r.text.includes(marker), marker);
+ const types = jsonLd(r.text).map((x) => x['@type']);
+ assert.ok(types.includes('WebSite'), types.join(','));
+ assert.ok(types.includes('WebApplication'), types.join(','));
+ });
+
+ await check('a page drawn for a signed-in person is private, no-store', async () => {
+ const r = await t.get('/', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ });
+
+ await check('POST /ask saves an answer and redirects to its private page', async () => {
+ const r = await t.get('/ask', { as: alice, multipart: { fields: { task: 'describe', question: 'What is this?' }, image: png } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.match(r.headers.get('location'), /^\/a\/vis_/);
+ const id = r.headers.get('location').split('/').pop();
+ const page = await t.get(`/a/${id}`, { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="vi-answer"'));
+ assert.ok(page.text.includes('red bicycle'));
+ assert.strictEqual((await t.get(`/a/${id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/a/${id}`, { as: mod })).status, 200);
+ assert.strictEqual((await t.get(`/a/${id}`)).status, 404);
+ });
+
+ await check('POST /ocr works without an account and renders the reading in the page', async () => {
+ const r = await t.get('/ocr', { multipart: { image: png } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('id="vi-ocr-result"'));
+ assert.ok(r.text.includes('HELLO VISION'));
+ const saved = await t.get('/ocr', { as: alice, multipart: { image: png } });
+ assert.strictEqual(saved.status, 303);
+ assert.match(saved.headers.get('location'), /^\/a\/vis_/);
+ });
+
+ await check('/compare lists hints and a signed-in compare redirects to a saved answer', async () => {
+ const form = await t.get('/compare', { as: alice });
+ assert.strictEqual(form.status, 200);
+ assert.ok(form.text.includes('id="vi-compare-form"'));
+ assert.ok(form.text.includes('mock:mock-vision-one'));
+ const r = await t.get('/compare', { as: alice, multipart: { fields: { question: 'Read it.', models: ['mock:one'] }, image: png } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.match(r.headers.get('location'), /^\/a\/vis_/);
+ });
+
+ await check('POST /ui-check works anonymously and saves for a signed-in person', async () => {
+ const anon = await t.get('/ui-check', { multipart: { fields: { html: PLANTED, viewport_w: '390', viewport_h: '844' } } });
+ assert.strictEqual(anon.status, 200, anon.text);
+ assert.ok(anon.text.includes('id="vi-ui-result"'));
+ assert.ok(anon.text.includes('vi-finding-overflow'));
+ assert.ok(anon.text.includes('vi-finding-contrast'));
+ const saved = await t.get('/ui-check', { as: alice, multipart: { fields: { html: PLANTED, viewport_w: '390', viewport_h: '844' } } });
+ assert.strictEqual(saved.status, 303, saved.text);
+ assert.match(saved.headers.get('location'), /^\/ui\/vuc_/);
+ const id = saved.headers.get('location').split('/').pop();
+ const page = await t.get(`/ui/${id}`, { as: alice });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('id="vi-check"'));
+ assert.ok(page.text.includes('id="vi-findings"'));
+ assert.strictEqual((await t.get(`/ui/${id}`, { as: bob })).status, 404);
+ });
+
+ await check('/developers names the API and the capabilities; the 404 is the Vision page', async () => {
+ const dev = await t.get('/developers');
+ assert.strictEqual(dev.status, 200);
+ assert.strictEqual(dev.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(dev.text.includes('id="vi-developers"'));
+ assert.ok(dev.text.includes('vision.ui.check'));
+ assert.ok(dev.text.includes('/api/v1/ui-check'));
+ 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="vi-not-found"'));
+ });
+
+ await check('assets are content-hashed; robots and sitemap list the public pages', async () => {
+ const v = assetVersion('css/vision.css');
+ const hashed = await t.get(`/css/vision.css?v=${v}`);
+ assert.strictEqual(hashed.status, 200);
+ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ const wrong = await t.get('/css/vision.css?v=deadbeefdeadbeef');
+ assert.strictEqual(wrong.status, 200);
+ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
+ const robots = await t.get('/robots.txt');
+ assert.match(robots.text, /Disallow: \/api\//);
+ assert.match(robots.text, /Disallow: \/a\//);
+ const map = await t.get('/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.ok(map.text.includes('https://openvibe.vision/ui-check'));
+ const llms = await t.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.ok(llms.text.includes('OpenVibe.Vision'));
+ assert.match(llms.text, /No face recognition/);
+ });
+ } 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, an
+ * in-process mock of OpenVibe.Network, a mock OpenVibe.AI and local image/page fixtures; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- ocr ui # 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,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+/**
+ * The public-URL guard, token audiences and signatures, and the promise that no secret is rendered or logged.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { assertPublicUrl, isPrivateAddress, loadImage } = require('../server/vision/images');
+
+(async () => {
+ await check('private, loopback, link-local and unique-local addresses are refused', async () => {
+ for (const a of ['127.0.0.1', '10.1.2.3', '172.16.0.1', '192.168.1.1', '169.254.169.254', '::1', 'fd00::1', 'fe80::1', '0.0.0.0']) assert.strictEqual(isPrivateAddress(a), true, a);
+ for (const a of ['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111']) assert.strictEqual(isPrivateAddress(a), false, a);
+ await assert.rejects(assertPublicUrl('http://127.0.0.1/x', { allowPrivate: false }), (e) => e.code === 'image.private_url');
+ await assert.rejects(assertPublicUrl('file:///etc/passwd'), (e) => e.code === 'image.invalid_url');
+ await assert.rejects(assertPublicUrl('https://user:[email protected]/x'), (e) => e.code === 'image.invalid_url');
+ });
+
+ const t = await boot();
+ const user = t.network.addUser('kim');
+ try {
+ await check('loadImage refuses a private URL unless the host explicitly allows it', async () => {
+ await assert.rejects(loadImage({ imageUrl: `${t.images.url}/image.png`, config: { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: false } }, fetchImpl: t.fetchImpl }), (e) => e.code === 'image.private_url');
+ const ok = await loadImage({ imageUrl: `${t.images.url}/image.png`, config: { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: true } } });
+ assert.strictEqual(ok.source, 'url');
+ assert.strictEqual(ok.mime, 'image/png');
+ assert.match(ok.sha256, /^[0-9a-f]{64}$/);
+ });
+
+ await check('a service token for another audience and a forged token are 401', async () => {
+ const wrongAud = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'], audience: 'openvibe.design' });
+ const r = await t.get('/api/v1/ui-check', { bearer: wrongAud, json: { html: '<p>x</p>' } });
+ assert.strictEqual(r.status, 401);
+ const forged = t.network.serviceToken({ sub: 'svc:design', cap: ['vision.ui.check'] }).slice(0, -2) + 'aa';
+ assert.strictEqual((await t.get('/api/v1/ui-check', { bearer: forged, json: { html: '<p>x</p>' } })).status, 401);
+ });
+
+ await check('the session cookie never appears in a page or /auth/me, and the logs carry no token', async () => {
+ const token = t.network.userToken(user);
+ const page = await t.get('/', { cookie: `vision_at=${token}` });
+ assert.strictEqual(page.status, 200);
+ assert.ok(!page.text.includes(token));
+ const me = await t.get('/auth/me', { cookie: `vision_at=${token}` });
+ assert.ok(!me.text.includes(token));
+ assert.ok(!t.logs().includes(token));
+ assert.ok(!t.logs().includes('vision-secret'));
+ });
+
+ await check('an image URL that redirects to a private address is still refused when the guard is on', async () => {
+ const local = { images: { maxMb: 8, timeoutMs: 2000, allowPrivate: false } };
+ await assert.rejects(loadImage({ imageUrl: `${t.images.url}/redirect`, config: local }), (e) => e.code === 'image.private_url');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id vision, cookies vision_at/vision_rt/vision_oauth), 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 vision', async () => {
+ const r = await t.get('/auth/login?next=/ask');
+ 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'), 'vision');
+ 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'), /vision_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=/ask');
+ 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'), '/ask');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /vision_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /vision_rt=[^;]+;.*HttpOnly/i);
+ assert.match(set, /vision_oauth=;/);
+ const at = set.match(/vision_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `vision_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(/vision_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://openvibe.vision' };
+ assert.strictEqual(sanitizeNext('/ask?x=1', config), '/ask?x=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.vision/ui-check', config), '/ui-check');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.vision.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ });
+
+ await check('readiness is truthful: db required; JWKS, OAuth, AI, Valkey and local OCR 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.ok('ai' in b.checks);
+ assert.ok('local_ocr' in b.checks);
+ 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, 'vision');
+ 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 ['/', '/ask', '/ocr', '/compare', '/ui-check', '/developers']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript><nav/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
+ const ping = await t.get('/api/v1/ping');
+ assert.strictEqual(ping.status, 200);
+ assert.deepStrictEqual(ping.json(), { ok: true, service: 'vision' });
+ 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: `vision_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `vision_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: `vision_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: 'vision_at=garbage' })).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.match(cross.text, /method="post"/);
+ assert.strictEqual((await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
+ const same = await t.get('/auth/logout?next=/ask', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/ask');
+ assert.match(String(same.headers.get('set-cookie') || ''), /vision_at=;/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+/**
+ * The UI check: a planted overflow, cut-off line, low contrast, overlapping boxes and a layering fault are found in
+ * static HTML; the same page is fetched from a URL; a screenshot gets the OCR-box and metadata pass; and the colour
+ * maths is the WCAG ratio.
+ */
+const assert = require('assert');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+const { checkHtml, contrastRatio, parseColor } = require('../server/vision/ui-check');
+
+const PLANTED = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
+body{background:#ffffff;color:#9a9a9a;font-size:16px}
+.wide{width:1400px;height:40px;background:#eeeeee}
+.cut{width:120px;overflow:hidden;white-space:nowrap}
+.a,.b{position:absolute;top:20px;width:200px;height:100px;background:#dddddd}
+.a{left:20px}.b{left:100px;top:60px;z-index:3}
+.ghost{z-index:9}
+.fixed{position:fixed;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,.2)}
+</style></head><body><p class="ghost">Low contrast words here</p><div class="wide">wide</div><div class="cut">This is a long line that will be cut off</div><div class="a">A</div><div class="b">B</div><div class="fixed">overlay</div></body></html>`;
+
+(async () => {
+ await check('WCAG contrast maths', async () => {
+ assert.ok(Math.abs(contrastRatio(parseColor('#000'), parseColor('#fff')) - 21) < 0.1);
+ assert.ok(Math.abs(contrastRatio(parseColor('#777'), parseColor('#fff')) - 4.48) < 0.1);
+ assert.ok(contrastRatio(parseColor('#999'), parseColor('#fff')) < 3);
+ });
+
+ await check('a planted overflow, cut-off, low contrast, overlap and layering fault are all found', async () => {
+ const out = checkHtml(PLANTED, { viewport: { w: 1280, h: 800 } });
+ assert.strictEqual(out.engine, 'static-html');
+ for (const kind of ['overflow', 'cut_off', 'contrast', 'overlap', 'layering']) {
+ assert.ok(out.findings.some((f) => f.kind === kind), `missing ${kind}: ${JSON.stringify(out.counts)}`);
+ }
+ assert.ok(out.findings.every((f) => f.severity && f.selector && f.detail));
+ assert.match(out.summary, /UI faults? found/);
+ assert.ok(out.warnings.some((w) => /JavaScript was not executed/.test(w)));
+ });
+
+ await check('a narrow viewport turns a 1400px card into an overflow; 1280px still reports it', async () => {
+ const narrow = checkHtml(PLANTED, { viewport: { w: 390, h: 844 } });
+ assert.ok(narrow.findings.some((f) => f.kind === 'overflow' && f.evidence.viewport === 390));
+ const out = checkHtml('<div style="width:100vw;padding-left:20px;box-sizing:content-box">x</div>', { viewport: { w: 390, h: 800 } });
+ assert.ok(out.findings.some((f) => f.kind === 'overflow'));
+ const percent = checkHtml('<style>body{margin:0}.x{width:100%;padding-left:20px;box-sizing:content-box}</style><div class="x">x</div>', { viewport: { w: 390, h: 800 } });
+ assert.ok(percent.findings.some((f) => f.kind === 'overflow'));
+ });
+
+ const t = await boot();
+ try {
+ await check('POST /ui-check returns saved findings with a stable vuc_ id', async () => {
+ const r = await t.get('/api/v1/ui-check', { json: { html: PLANTED, viewport: { w: 390, h: 844 } } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json().check;
+ assert.match(c.id, /^vuc_/);
+ assert.strictEqual(c.source, 'html');
+ assert.strictEqual(c.engine, 'static-html');
+ assert.strictEqual(c.viewport.w, 390);
+ assert.ok(c.findings.length >= 4);
+ const saved = await t.ctx.store.uiCheckById(c.id);
+ assert.strictEqual(saved.findings.length, c.findings.length);
+ });
+
+ await check('a URL is fetched and checked locally', async () => {
+ const r = await t.get('/api/v1/ui-check', { json: { url: `${t.pages.url}/page`, viewport: { w: 390, h: 844 } } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json().check;
+ assert.strictEqual(c.source, 'url');
+ assert.match(c.url, /\/page$/);
+ assert.ok(c.summary);
+ });
+
+ await check('a screenshot gets the local OCR-box and pixel pass, and says what it cannot see', async () => {
+ const png = await sharp({ create: { width: 640, height: 220, channels: 4, background: '#ffffff' } }).png().toBuffer();
+ const r = await t.get('/api/v1/ui-check', { multipart: { image: png } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json().check;
+ assert.strictEqual(c.engine, 'screenshot');
+ assert.strictEqual(c.source, 'screenshot');
+ assert.strictEqual(c.viewport.w, 640);
+ assert.ok(c.summary.warnings.some((w) => /cannot show CSS layering/.test(w)));
+ assert.ok(Array.isArray(c.findings));
+ });
+
+ await check('the API refuses an empty UI check and an oversized HTML body', async () => {
+ const empty = await t.get('/api/v1/ui-check', { json: {} });
+ assert.strictEqual(empty.status, 400);
+ assert.strictEqual(empty.json().code, 'vision.invalid');
+ const big = 'x'.repeat((t.config.ui.maxHtmlBytes || 1024 * 1024) + 10);
+ const tooBig = await t.get('/api/v1/ui-check', { json: { html: big } });
+ assert.ok([413, 400].includes(tooBig.status), String(tooBig.status));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();