openvibers/openvibe.actor Commits

OpenVibe.Actor 0.1.0: OpenVibe's own agent and the router for agent work (plan T17, ADR-044).

OP OpenVibers committed 8 Oct 2026 · 4304f16800 · 64 files, +7755 −0

A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it, run with
its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost and the
explanation; a failed agent or check hands it on. Agents: the OpenVibe runtime (DeepSeek acting through
OpenVibe.Search and OpenVibe.Tools), OpenAI's web agent, an open model on OpenVibe's server for private mode;
OpenVibe.Codes is listed for coding. API with actor.task.* capabilities (openvibe-contracts 0.115.0), a free tier per
person and an operator daily ceiling, server-rendered pages that work without JavaScript, deploy files for ovhost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01K8AYn3FF3HE7CPfM262qdh

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..52a0bfe
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,49 @@
++# OpenVibe.Actor — environment (production: /etc/openvibe/actor.env). Names only; never commit values.
++
++NODE_ENV=production
++PORT=4950
++HOST=127.0.0.1
++BASE_URL=https://openvibe.actor
++TRUST_PROXY=2
++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh actor: the runtime role through PgBouncer,
++# the owner on a direct connection (migrations), and Valkey (shared limit counters).
++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=
++# Per-caller limits (server/http/caller-limits.js): reads one caller may make to the API per minute and per hour.
++#ACTOR_LIMITS_MINUTE=120
++#ACTOR_LIMITS_HOUR=3000
++
++# OpenVibe.Network: SSO (OAuth client `actor`, PKCE S256) and its JWKS; app tokens for audience openvibe.actor.
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++OV_OAUTH_CLIENT_ID=actor
++OV_OAUTH_CLIENT_SECRET=
++# OV_OAUTH_REDIRECT_URI=https://openvibe.actor/auth/callback
++# ACTOR_AUDIENCE=openvibe.actor
++
++# The model providers behind the agents (server/agents/catalog.js). An agent without its key is listed as unavailable.
++ACTOR_DEEPSEEK_API_KEY=
++# ACTOR_DEEPSEEK_MODEL=deepseek-flash
++ACTOR_OPENAI_API_KEY=
++# ACTOR_OPENAI_MODEL=gpt-5-mini
++# ACTOR_OPENAI_CHECK_MODEL=gpt-5-nano
++# The open model on this server (private mode): an OpenAI-compatible server such as llama.cpp.
++ACTOR_LOCAL_LLM_URL=
++ACTOR_LOCAL_LLM_MODEL=
++# The OpenVibe services the runtime acts through.
++# ACTOR_SEARCH_URL=https://search.openvibe.network
++# ACTOR_TOOLS_URL=https://openvibe.tools
++
++# Money: the free tier per person, and the operator's ceiling over everyone in one UTC day (USD).
++#ACTOR_FREE_TASKS_PER_DAY=20
++#ACTOR_FREE_PER_TASK_USD=0.05
++#ACTOR_FREE_PER_DAY_USD=0.25
++#ACTOR_MAX_COST_USD_PER_DAY=2
++#ACTOR_MAX_RUNNING=4
++#ACTOR_TASK_TIMEOUT_MS=180000
++
++# optional: defaults to true in production
++# COOKIE_SECURE=true
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..49953e6
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,18 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
++ test:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
++ with:
++ syntax-dirs: server public/js test
++ contracts-service: actor
++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..c7d9202
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,11 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.log
++*.pem
++*.key
++!server/data/
++node_modules
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..0985338
---- /dev/null
++++ b/CHANGELOG.md
@@@ -0,0 +1,10 @@
++# Changelog
++
++What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
++
++## 0.1.0 — 2026-10-08
++
++- **First release:** the task router (plan T17, ADR-044). A task is classified, routed by `openvibe-sdk/placement`, run, checked by a second model family, and delivered as `platform.task@1` with its cost and explanation.
++- **Agents:** the OpenVibe runtime (DeepSeek acting through OpenVibe.Search and OpenVibe.Tools), OpenAI's web agent, and an open model on OpenVibe's server for private mode. OpenVibe.Codes is listed for coding.
++- **API:** tasks, a live stream, cancel, the agent catalog and a dry-run router. Capabilities: `actor.task.create`, `actor.task.read`, `actor.task.list`, `actor.agent.read` (openvibe-contracts 0.115.0).
++- **Money:** a free tier per person and an operator daily ceiling. Budgets are hard.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
++ Everyone is permitted to copy and distribute verbatim copies
++ of this license document, but changing it is not allowed.
++
++ Preamble
++
++ The GNU Affero General Public License is a free, copyleft license for
++software and other kinds of works, specifically designed to ensure
++cooperation with the community in the case of network server software.
++
++ The licenses for most software and other practical works are designed
++to take away your freedom to share and change the works. By contrast,
++our General Public Licenses are intended to guarantee your freedom to
++share and change all versions of a program--to make sure it remains free
++software for all its users.
++
++ When we speak of free software, we are referring to freedom, not
++price. Our General Public Licenses are designed to make sure that you
++have the freedom to distribute copies of free software (and charge for
++them if you wish), that you receive source code or can get it if you
++want it, that you can change the software or use pieces of it in new
++free programs, and that you know you can do these things.
++
++ Developers that use our General Public Licenses protect your rights
++with two steps: (1) assert copyright on the software, and (2) offer
++you this License which gives you legal permission to copy, distribute
++and/or modify the software.
++
++ A secondary benefit of defending all users' freedom is that
++improvements made in alternate versions of the program, if they
++receive widespread use, become available for other developers to
++incorporate. Many developers of free software are heartened and
++encouraged by the resulting cooperation. However, in the case of
++software used on network servers, this result may fail to come about.
++The GNU General Public License permits making a modified version and
++letting the public access it on a server without ever releasing its
++source code to the public.
++
++ The GNU Affero General Public License is designed specifically to
++ensure that, in such cases, the modified source code becomes available
++to the community. It requires the operator of a network server to
++provide the source code of the modified version running there to the
++users of that server. Therefore, public use of a modified version, on
++a publicly accessible server, gives the public access to the source
++code of the modified version.
++
++ An older license, called the Affero General Public License and
++published by Affero, was designed to accomplish similar goals. This is
++a different license, not a version of the Affero GPL, but Affero has
++released a new version of the Affero GPL which permits relicensing under
++this license.
++
++ The precise terms and conditions for copying, distribution and
++modification follow.
++
++ TERMS AND CONDITIONS
++
++ 0. Definitions.
++
++ "This License" refers to version 3 of the GNU Affero General Public License.
++
++ "Copyright" also means copyright-like laws that apply to other kinds of
++works, such as semiconductor masks.
++
++ "The Program" refers to any copyrightable work licensed under this
++License. Each licensee is addressed as "you". "Licensees" and
++"recipients" may be individuals or organizations.
++
++ To "modify" a work means to copy from or adapt all or part of the work
++in a fashion requiring copyright permission, other than the making of an
++exact copy. The resulting work is called a "modified version" of the
++earlier work or a work "based on" the earlier work.
++
++ A "covered work" means either the unmodified Program or a work based
++on the Program.
++
++ To "propagate" a work means to do anything with it that, without
++permission, would make you directly or secondarily liable for
++infringement under applicable copyright law, except executing it on a
++computer or modifying a private copy. Propagation includes copying,
++distribution (with or without modification), making available to the
++public, and in some countries other activities as well.
++
++ To "convey" a work means any kind of propagation that enables other
++parties to make or receive copies. Mere interaction with a user through
++a computer network, with no transfer of a copy, is not conveying.
++
++ An interactive user interface displays "Appropriate Legal Notices"
++to the extent that it includes a convenient and prominently visible
++feature that (1) displays an appropriate copyright notice, and (2)
++tells the user that there is no warranty for the work (except to the
++extent that warranties are provided), that licensees may convey the
++work under this License, and how to view a copy of this License. If
++the interface presents a list of user commands or options, such as a
++menu, a prominent item in the list meets this criterion.
++
++ 1. Source Code.
++
++ The "source code" for a work means the preferred form of the work
++for making modifications to it. "Object code" means any non-source
++form of a work.
++
++ A "Standard Interface" means an interface that either is an official
++standard defined by a recognized standards body, or, in the case of
++interfaces specified for a particular programming language, one that
++is widely used among developers working in that language.
++
++ The "System Libraries" of an executable work include anything, other
++than the work as a whole, that (a) is included in the normal form of
++packaging a Major Component, but which is not part of that Major
++Component, and (b) serves only to enable use of the work with that
++Major Component, or to implement a Standard Interface for which an
++implementation is available to the public in source code form. A
++"Major Component", in this context, means a major essential component
++(kernel, window system, and so on) of the specific operating system
++(if any) on which the executable work runs, or a compiler used to
++produce the work, or an object code interpreter used to run it.
++
++ The "Corresponding Source" for a work in object code form means all
++the source code needed to generate, install, and (for an executable
++work) run the object code and to modify the work, including scripts to
++control those activities. However, it does not include the work's
++System Libraries, or general-purpose tools or generally available free
++programs which are used unmodified in performing those activities but
++which are not part of the work. For example, Corresponding Source
++includes interface definition files associated with source files for
++the work, and the source code for shared libraries and dynamically
++linked subprograms that the work is specifically designed to require,
++such as by intimate data communication or control flow between those
++subprograms and other parts of the work.
++
++ The Corresponding Source need not include anything that users
++can regenerate automatically from other parts of the Corresponding
++Source.
++
++ The Corresponding Source for a work in source code form is that
++same work.
++
++ 2. Basic Permissions.
++
++ All rights granted under this License are granted for the term of
++copyright on the Program, and are irrevocable provided the stated
++conditions are met. This License explicitly affirms your unlimited
++permission to run the unmodified Program. The output from running a
++covered work is covered by this License only if the output, given its
++content, constitutes a covered work. This License acknowledges your
++rights of fair use or other equivalent, as provided by copyright law.
++
++ You may make, run and propagate covered works that you do not
++convey, without conditions so long as your license otherwise remains
++in force. You may convey covered works to others for the sole purpose
++of having them make modifications exclusively for you, or provide you
++with facilities for running those works, provided that you comply with
++the terms of this License in conveying all material for which you do
++not control copyright. Those thus making or running the covered works
++for you must do so exclusively on your behalf, under your direction
++and control, on terms that prohibit them from making any copies of
++your copyrighted material outside their relationship with you.
++
++ Conveying under any other circumstances is permitted solely under
++the conditions stated below. Sublicensing is not allowed; section 10
++makes it unnecessary.
++
++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
++
++ No covered work shall be deemed part of an effective technological
++measure under any applicable law fulfilling obligations under article
++11 of the WIPO copyright treaty adopted on 20 December 1996, or
++similar laws prohibiting or restricting circumvention of such
++measures.
++
++ When you convey a covered work, you waive any legal power to forbid
++circumvention of technological measures to the extent such circumvention
++is effected by exercising rights under this License with respect to
++the covered work, and you disclaim any intention to limit operation or
++modification of the work as a means of enforcing, against the work's
++users, your or third parties' legal rights to forbid circumvention of
++technological measures.
++
++ 4. Conveying Verbatim Copies.
++
++ You may convey verbatim copies of the Program's source code as you
++receive it, in any medium, provided that you conspicuously and
++appropriately publish on each copy an appropriate copyright notice;
++keep intact all notices stating that this License and any
++non-permissive terms added in accord with section 7 apply to the code;
++keep intact all notices of the absence of any warranty; and give all
++recipients a copy of this License along with the Program.
++
++ You may charge any price or no price for each copy that you convey,
++and you may offer support or warranty protection for a fee.
++
++ 5. Conveying Modified Source Versions.
++
++ You may convey a work based on the Program, or the modifications to
++produce it from the Program, in the form of source code under the
++terms of section 4, provided that you also meet all of these conditions:
++
++ a) The work must carry prominent notices stating that you modified
++ it, and giving a relevant date.
++
++ b) The work must carry prominent notices stating that it is
++ released under this License and any conditions added under section
++ 7. This requirement modifies the requirement in section 4 to
++ "keep intact all notices".
++
++ c) You must license the entire work, as a whole, under this
++ License to anyone who comes into possession of a copy. This
++ License will therefore apply, along with any applicable section 7
++ additional terms, to the whole of the work, and all its parts,
++ regardless of how they are packaged. This License gives no
++ permission to license the work in any other way, but it does not
++ invalidate such permission if you have separately received it.
++
++ d) If the work has interactive user interfaces, each must display
++ Appropriate Legal Notices; however, if the Program has interactive
++ interfaces that do not display Appropriate Legal Notices, your
++ work need not make them do so.
++
++ A compilation of a covered work with other separate and independent
++works, which are not by their nature extensions of the covered work,
++and which are not combined with it such as to form a larger program,
++in or on a volume of a storage or distribution medium, is called an
++"aggregate" if the compilation and its resulting copyright are not
++used to limit the access or legal rights of the compilation's users
++beyond what the individual works permit. Inclusion of a covered work
++in an aggregate does not cause this License to apply to the other
++parts of the aggregate.
++
++ 6. Conveying Non-Source Forms.
++
++ You may convey a covered work in object code form under the terms
++of sections 4 and 5, provided that you also convey the
++machine-readable Corresponding Source under the terms of this License,
++in one of these ways:
++
++ a) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by the
++ Corresponding Source fixed on a durable physical medium
++ customarily used for software interchange.
++
++ b) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by a
++ written offer, valid for at least three years and valid for as
++ long as you offer spare parts or customer support for that product
++ model, to give anyone who possesses the object code either (1) a
++ copy of the Corresponding Source for all the software in the
++ product that is covered by this License, on a durable physical
++ medium customarily used for software interchange, for a price no
++ more than your reasonable cost of physically performing this
++ conveying of source, or (2) access to copy the
++ Corresponding Source from a network server at no charge.
++
++ c) Convey individual copies of the object code with a copy of the
++ written offer to provide the Corresponding Source. This
++ alternative is allowed only occasionally and noncommercially, and
++ only if you received the object code with such an offer, in accord
++ with subsection 6b.
++
++ d) Convey the object code by offering access from a designated
++ place (gratis or for a charge), and offer equivalent access to the
++ Corresponding Source in the same way through the same place at no
++ further charge. You need not require recipients to copy the
++ Corresponding Source along with the object code. If the place to
++ copy the object code is a network server, the Corresponding Source
++ may be on a different server (operated by you or a third party)
++ that supports equivalent copying facilities, provided you maintain
++ clear directions next to the object code saying where to find the
++ Corresponding Source. Regardless of what server hosts the
++ Corresponding Source, you remain obligated to ensure that it is
++ available for as long as needed to satisfy these requirements.
++
++ e) Convey the object code using peer-to-peer transmission, provided
++ you inform other peers where the object code and Corresponding
++ Source of the work are being offered to the general public at no
++ charge under subsection 6d.
++
++ A separable portion of the object code, whose source code is excluded
++from the Corresponding Source as a System Library, need not be
++included in conveying the object code work.
++
++ A "User Product" is either (1) a "consumer product", which means any
++tangible personal property which is normally used for personal, family,
++or household purposes, or (2) anything designed or sold for incorporation
++into a dwelling. In determining whether a product is a consumer product,
++doubtful cases shall be resolved in favor of coverage. For a particular
++product received by a particular user, "normally used" refers to a
++typical or common use of that class of product, regardless of the status
++of the particular user or of the way in which the particular user
++actually uses, or expects or is expected to use, the product. A product
++is a consumer product regardless of whether the product has substantial
++commercial, industrial or non-consumer uses, unless such uses represent
++the only significant mode of use of the product.
++
++ "Installation Information" for a User Product means any methods,
++procedures, authorization keys, or other information required to install
++and execute modified versions of a covered work in that User Product from
++a modified version of its Corresponding Source. The information must
++suffice to ensure that the continued functioning of the modified object
++code is in no case prevented or interfered with solely because
++modification has been made.
++
++ If you convey an object code work under this section in, or with, or
++specifically for use in, a User Product, and the conveying occurs as
++part of a transaction in which the right of possession and use of the
++User Product is transferred to the recipient in perpetuity or for a
++fixed term (regardless of how the transaction is characterized), the
++Corresponding Source conveyed under this section must be accompanied
++by the Installation Information. But this requirement does not apply
++if neither you nor any third party retains the ability to install
++modified object code on the User Product (for example, the work has
++been installed in ROM).
++
++ The requirement to provide Installation Information does not include a
++requirement to continue to provide support service, warranty, or updates
++for a work that has been modified or installed by the recipient, or for
++the User Product in which it has been modified or installed. Access to a
++network may be denied when the modification itself materially and
++adversely affects the operation of the network or violates the rules and
++protocols for communication across the network.
++
++ Corresponding Source conveyed, and Installation Information provided,
++in accord with this section must be in a format that is publicly
++documented (and with an implementation available to the public in
++source code form), and must require no special password or key for
++unpacking, reading or copying.
++
++ 7. Additional Terms.
++
++ "Additional permissions" are terms that supplement the terms of this
++License by making exceptions from one or more of its conditions.
++Additional permissions that are applicable to the entire Program shall
++be treated as though they were included in this License, to the extent
++that they are valid under applicable law. If additional permissions
++apply only to part of the Program, that part may be used separately
++under those permissions, but the entire Program remains governed by
++this License without regard to the additional permissions.
++
++ When you convey a copy of a covered work, you may at your option
++remove any additional permissions from that copy, or from any part of
++it. (Additional permissions may be written to require their own
++removal in certain cases when you modify the work.) You may place
++additional permissions on material, added by you to a covered work,
++for which you have or can give appropriate copyright permission.
++
++ Notwithstanding any other provision of this License, for material you
++add to a covered work, you may (if authorized by the copyright holders of
++that material) supplement the terms of this License with terms:
++
++ a) Disclaiming warranty or limiting liability differently from the
++ terms of sections 15 and 16 of this License; or
++
++ b) Requiring preservation of specified reasonable legal notices or
++ author attributions in that material or in the Appropriate Legal
++ Notices displayed by works containing it; or
++
++ c) Prohibiting misrepresentation of the origin of that material, or
++ requiring that modified versions of such material be marked in
++ reasonable ways as different from the original version; or
++
++ d) Limiting the use for publicity purposes of names of licensors or
++ authors of the material; or
++
++ e) Declining to grant rights under trademark law for use of some
++ trade names, trademarks, or service marks; or
++
++ f) Requiring indemnification of licensors and authors of that
++ material by anyone who conveys the material (or modified versions of
++ it) with contractual assumptions of liability to the recipient, for
++ any liability that these contractual assumptions directly impose on
++ those licensors and authors.
++
++ All other non-permissive additional terms are considered "further
++restrictions" within the meaning of section 10. If the Program as you
++received it, or any part of it, contains a notice stating that it is
++governed by this License along with a term that is a further
++restriction, you may remove that term. If a license document contains
++a further restriction but permits relicensing or conveying under this
++License, you may add to a covered work material governed by the terms
++of that license document, provided that the further restriction does
++not survive such relicensing or conveying.
++
++ If you add terms to a covered work in accord with this section, you
++must place, in the relevant source files, a statement of the
++additional terms that apply to those files, or a notice indicating
++where to find the applicable terms.
++
++ Additional terms, permissive or non-permissive, may be stated in the
++form of a separately written license, or stated as exceptions;
++the above requirements apply either way.
++
++ 8. Termination.
++
++ You may not propagate or modify a covered work except as expressly
++provided under this License. Any attempt otherwise to propagate or
++modify it is void, and will automatically terminate your rights under
++this License (including any patent licenses granted under the third
++paragraph of section 11).
++
++ However, if you cease all violation of this License, then your
++license from a particular copyright holder is reinstated (a)
++provisionally, unless and until the copyright holder explicitly and
++finally terminates your license, and (b) permanently, if the copyright
++holder fails to notify you of the violation by some reasonable means
++prior to 60 days after the cessation.
++
++ Moreover, your license from a particular copyright holder is
++reinstated permanently if the copyright holder notifies you of the
++violation by some reasonable means, this is the first time you have
++received notice of violation of this License (for any work) from that
++copyright holder, and you cure the violation prior to 30 days after
++your receipt of the notice.
++
++ Termination of your rights under this section does not terminate the
++licenses of parties who have received copies or rights from you under
++this License. If your rights have been terminated and not permanently
++reinstated, you do not qualify to receive new licenses for the same
++material under section 10.
++
++ 9. Acceptance Not Required for Having Copies.
++
++ You are not required to accept this License in order to receive or
++run a copy of the Program. Ancillary propagation of a covered work
++occurring solely as a consequence of using peer-to-peer transmission
++to receive a copy likewise does not require acceptance. However,
++nothing other than this License grants you permission to propagate or
++modify any covered work. These actions infringe copyright if you do
++not accept this License. Therefore, by modifying or propagating a
++covered work, you indicate your acceptance of this License to do so.
++
++ 10. Automatic Licensing of Downstream Recipients.
++
++ Each time you convey a covered work, the recipient automatically
++receives a license from the original licensors, to run, modify and
++propagate that work, subject to this License. You are not responsible
++for enforcing compliance by third parties with this License.
++
++ An "entity transaction" is a transaction transferring control of an
++organization, or substantially all assets of one, or subdividing an
++organization, or merging organizations. If propagation of a covered
++work results from an entity transaction, each party to that
++transaction who receives a copy of the work also receives whatever
++licenses to the work the party's predecessor in interest had or could
++give under the previous paragraph, plus a right to possession of the
++Corresponding Source of the work from the predecessor in interest, if
++the predecessor has it or can get it with reasonable efforts.
++
++ You may not impose any further restrictions on the exercise of the
++rights granted or affirmed under this License. For example, you may
++not impose a license fee, royalty, or other charge for exercise of
++rights granted under this License, and you may not initiate litigation
++(including a cross-claim or counterclaim in a lawsuit) alleging that
++any patent claim is infringed by making, using, selling, offering for
++sale, or importing the Program or any portion of it.
++
++ 11. Patents.
++
++ A "contributor" is a copyright holder who authorizes use under this
++License of the Program or a work on which the Program is based. The
++work thus licensed is called the contributor's "contributor version".
++
++ A contributor's "essential patent claims" are all patent claims
++owned or controlled by the contributor, whether already acquired or
++hereafter acquired, that would be infringed by some manner, permitted
++by this License, of making, using, or selling its contributor version,
++but do not include claims that would be infringed only as a
++consequence of further modification of the contributor version. For
++purposes of this definition, "control" includes the right to grant
++patent sublicenses in a manner consistent with the requirements of
++this License.
++
++ Each contributor grants you a non-exclusive, worldwide, royalty-free
++patent license under the contributor's essential patent claims, to
++make, use, sell, offer for sale, import and otherwise run, modify and
++propagate the contents of its contributor version.
++
++ In the following three paragraphs, a "patent license" is any express
++agreement or commitment, however denominated, not to enforce a patent
++(such as an express permission to practice a patent or covenant not to
++sue for patent infringement). To "grant" such a patent license to a
++party means to make such an agreement or commitment not to enforce a
++patent against the party.
++
++ If you convey a covered work, knowingly relying on a patent license,
++and the Corresponding Source of the work is not available for anyone
++to copy, free of charge and under the terms of this License, through a
++publicly available network server or other readily accessible means,
++then you must either (1) cause the Corresponding Source to be so
++available, or (2) arrange to deprive yourself of the benefit of the
++patent license for this particular work, or (3) arrange, in a manner
++consistent with the requirements of this License, to extend the patent
++license to downstream recipients. "Knowingly relying" means you have
++actual knowledge that, but for the patent license, your conveying the
++covered work in a country, or your recipient's use of the covered work
++in a country, would infringe one or more identifiable patents in that
++country that you have reason to believe are valid.
++
++ If, pursuant to or in connection with a single transaction or
++arrangement, you convey, or propagate by procuring conveyance of, a
++covered work, and grant a patent license to some of the parties
++receiving the covered work authorizing them to use, propagate, modify
++or convey a specific copy of the covered work, then the patent license
++you grant is automatically extended to all recipients of the covered
++work and works based on it.
++
++ A patent license is "discriminatory" if it does not include within
++the scope of its coverage, prohibits the exercise of, or is
++conditioned on the non-exercise of one or more of the rights that are
++specifically granted under this License. You may not convey a covered
++work if you are a party to an arrangement with a third party that is
++in the business of distributing software, under which you make payment
++to the third party based on the extent of your activity of conveying
++the work, and under which the third party grants, to any of the
++parties who would receive the covered work from you, a discriminatory
++patent license (a) in connection with copies of the covered work
++conveyed by you (or copies made from those copies), or (b) primarily
++for and in connection with specific products or compilations that
++contain the covered work, unless you entered into that arrangement,
++or that patent license was granted, prior to 28 March 2007.
++
++ Nothing in this License shall be construed as excluding or limiting
++any implied license or other defenses to infringement that may
++otherwise be available to you under applicable patent law.
++
++ 12. No Surrender of Others' Freedom.
++
++ If conditions are imposed on you (whether by court order, agreement or
++otherwise) that contradict the conditions of this License, they do not
++excuse you from the conditions of this License. If you cannot convey a
++covered work so as to satisfy simultaneously your obligations under this
++License and any other pertinent obligations, then as a consequence you may
++not convey it at all. For example, if you agree to terms that obligate you
++to collect a royalty for further conveying from those to whom you convey
++the Program, the only way you could satisfy both those terms and this
++License would be to refrain entirely from conveying the Program.
++
++ 13. Remote Network Interaction; Use with the GNU General Public License.
++
++ Notwithstanding any other provision of this License, if you modify the
++Program, your modified version must prominently offer all users
++interacting with it remotely through a computer network (if your version
++supports such interaction) an opportunity to receive the Corresponding
++Source of your version by providing access to the Corresponding Source
++from a network server at no charge, through some standard or customary
++means of facilitating copying of software. This Corresponding Source
++shall include the Corresponding Source for any work covered by version 3
++of the GNU General Public License that is incorporated pursuant to the
++following paragraph.
++
++ Notwithstanding any other provision of this License, you have
++permission to link or combine any covered work with a work licensed
++under version 3 of the GNU General Public License into a single
++combined work, and to convey the resulting work. The terms of this
++License will continue to apply to the part which is the covered work,
++but the work with which it is combined will remain governed by version
++3 of the GNU General Public License.
++
++ 14. Revised Versions of this License.
++
++ The Free Software Foundation may publish revised and/or new versions of
++the GNU Affero General Public License from time to time. Such new versions
++will be similar in spirit to the present version, but may differ in detail to
++address new problems or concerns.
++
++ Each version is given a distinguishing version number. If the
++Program specifies that a certain numbered version of the GNU Affero General
++Public License "or any later version" applies to it, you have the
++option of following the terms and conditions either of that numbered
++version or of any later version published by the Free Software
++Foundation. If the Program does not specify a version number of the
++GNU Affero General Public License, you may choose any version ever published
++by the Free Software Foundation.
++
++ If the Program specifies that a proxy can decide which future
++versions of the GNU Affero General Public License can be used, that proxy's
++public statement of acceptance of a version permanently authorizes you
++to choose that version for the Program.
++
++ Later license versions may give you additional or different
++permissions. However, no additional obligations are imposed on any
++author or copyright holder as a result of your choosing to follow a
++later version.
++
++ 15. Disclaimer of Warranty.
++
++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
++
++ 16. Limitation of Liability.
++
++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
++SUCH DAMAGES.
++
++ 17. Interpretation of Sections 15 and 16.
++
++ If the disclaimer of warranty and limitation of liability provided
++above cannot be given local legal effect according to their terms,
++reviewing courts shall apply local law that most closely approximates
++an absolute waiver of all civil liability in connection with the
++Program, unless a warranty or assumption of liability accompanies a
++copy of the Program in return for a fee.
++
++ END OF TERMS AND CONDITIONS
++
++ How to Apply These Terms to Your New Programs
++
++ If you develop a new program, and you want it to be of the greatest
++possible use to the public, the best way to achieve this is to make it
++free software which everyone can redistribute and change under these terms.
++
++ To do so, attach the following notices to the program. It is safest
++to attach them to the start of each source file to most effectively
++state the exclusion of warranty; and each file should have at least
++the "copyright" line and a pointer to where the full notice is found.
++
++ <one line to give the program's name and a brief idea of what it does.>
++ Copyright (C) <year> <name of author>
++
++ This program is free software: you can redistribute it and/or modify
++ it under the terms of the GNU Affero General Public License as published
++ by the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ This program is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU Affero General Public License for more details.
++
++ You should have received a copy of the GNU Affero General Public License
++ along with this program. If not, see <https://www.gnu.org/licenses/>.
++
++Also add information on how to contact you by electronic and paper mail.
++
++ If your software can interact with users remotely through a computer
++network, you should also make sure that it provides a way for users to
++get its source. For example, if your program is a web application, its
++interface could display a "Source" link that leads users to an archive
++of the code. There are many ways you could offer source, and different
++solutions will be better for different programs; see section 13 for the
++specific requirements.
++
++ You should also get your employer (if you work as a programmer) or school,
++if any, to sign a "copyright disclaimer" for the program, if necessary.
++For more information on this, and how to apply and follow the GNU AGPL, see
++<https://www.gnu.org/licenses/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..76f28f9
---- /dev/null
++++ b/README.md
@@@ -0,0 +1,131 @@
++# OpenVibe.Actor
++
++> Your agent for everything: give a task in plain words, and Actor sends it to the best agent for it — OpenVibe's own, acting through every OpenVibe service, or another platform's — has a second model check the answer, and shows you what it cost and why. Like OpenRouter, for agents.
++
++**Status:** alpha. Public at `https://openvibe.actor` (openvibe-ovh, unit `openvibe-actor` on 127.0.0.1:4950 behind nginx). See [STATUS.json](STATUS.json) for exactly what works and what does not.
++**Domain:** `openvibe.actor` · **Port:** 4950 · **Service id:** `actor`
++**Plan:** T17 (owner direction 2026-10-08: OpenVibe's own general agent, competing with the big labs' agents while able to use theirs; an OpenRouter for agents; cheap self-hosted browser and desktop agents). Decisions: [ADR-044](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-044-actor-runtime.md) (proposed).
++**License:** AGPL-3.0 (same as every OpenVibe service).
++
++## How a task runs
++
++1. **Sort it** ([server/agents/classify.js](server/agents/classify.js)): an answer, a lookup (a domain, address or site), research (pages it names, or OpenVibe's content), a live web search, or code. A cheap model decides, or a free heuristic in private mode, so the text never leaves OpenVibe there.
++2. **Route it** ([server/agents/router.js](server/agents/router.js)): `openvibe-sdk/placement` over the agent catalog. Hard requirements come first: the task class's capability, the trust private mode demands, each agent's health, and the budget as a cost ceiling. Then the mode's objective: cheapest, balanced (the default), best (high-quality agents first), fastest, or private (first-party only). The answer is a `platform.placement-result@1`, stored on the task as its explanation.
++3. **Run it** ([server/agents/adapters.js](server/agents/adapters.js)): the agent works while its steps stream as events. Every model call and web search is metered at once at the published rate card, so the cost is true even if the task is cancelled half-way.
++4. **Check it** ([server/agents/check.js](server/agents/check.js)): a model of another family reads the answer. DeepSeek's answers are checked by OpenAI's smallest model, and OpenAI's by DeepSeek. In private mode the open model checks itself, and the task says so. An answer that cannot be checked is never delivered.
++5. **Deliver or hand on** ([server/tasks/engine.js](server/tasks/engine.js)): a passed check gives the result (the answer, its sources, the agent and model, the check), the cost and the explanation. An agent error or a failed check sends the task to the next eligible agent (at most two), with each placement appended to the explanation.
++
++## The agents ([server/agents/catalog.js](server/agents/catalog.js))
++
++| Agent | Kind | Can do | Trust | Price |
++|---|---|---|---|---|
++| OpenVibe runtime | openvibe-runtime | answer, lookup, research | external (the model runs at DeepSeek) | DeepSeek Flash, $0.30 in / $1.20 out per 1M tokens (peak; off-peak is half, Actor charges peak) |
++| OpenAI agent | agent-platform | answer, research, web | external | gpt-5-mini, $0.25 / $2.00 per 1M, plus $0.01 a web search |
++| Open model on OpenVibe | open-model | answer | first-party | free (OpenVibe's own server; `openvibe-llm` on the host) |
++| OpenVibe.Codes | codes | code | first-party | listed, not hosted yet: coding runs need OpenVibe.Run sandboxes; use `openvibe-codes` on your machine |
++
++The runtime acts only through OpenVibe's public APIs ([server/agents/tools.js](server/agents/tools.js)):
++- OpenVibe.Search: content across the network;
++- OpenVibe.Tools' catalog and run API: DNS, mail records, certificates, headers, WHOIS, conversions and more, limited to tools any anonymous caller may run;
++- the Tools page reader (`read`): a web page as text.
++
++Actor itself never fetches a URL from a task or a model; Tools' own SSRF guard decides what a tool may reach.
++
++Rate cards are typed in by a person from each provider's price page, with the page and the day it was checked, and are never written by a model.
++
++## API
++
++| Route | Capability | |
++|---|---|---|
++| `POST /api/v1/tasks` | `actor.task.create` | `actor.task-create-request@1` → `platform.task@1` (201, queued) |
++| `GET /api/v1/tasks/:id` | `actor.task.read` | the task |
++| `GET /api/v1/tasks/:id/events` | `actor.task.read` | server-sent events, `actor.task-event@1`, resumable with `Last-Event-ID` |
++| `POST /api/v1/tasks/:id/cancel` | `actor.task.create` | stop it |
++| `GET /api/v1/tasks` | `actor.task.list` | `actor.task-list-result@1` |
++| `GET /api/v1/agents` | public | `actor.agent-list-result@1` |
++| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
++
++**Who can call it:**
++- **A person:** their Network token as a Bearer, or this site's session. A write made with the session must come from openvibe.actor itself (fetch metadata or Origin).
++- **An app, agent or service:** a token for audience `openvibe.actor` that holds the route's capability, granted on OpenVibe.Services. Its tasks belong to the token's project.
++
++**What a caller can see:** a task is visible only to its requester, or to tokens of its project. Anyone else gets 404, the same answer as a task that doesn't exist.
++
++**Errors:** RFC 9457 problems. The codes are on [openvibe.actor/docs](https://openvibe.actor/docs#errors).
++
++## Money
++
++- **Free tier, per person:** `ACTOR_FREE_TASKS_PER_DAY` (20) tasks a day, up to `ACTOR_FREE_PER_TASK_USD` ($0.05) a task and `ACTOR_FREE_PER_DAY_USD` ($0.25) a day.
++- **Operator ceiling:** `ACTOR_MAX_COST_USD_PER_DAY` ($2) over everyone; past it, new tasks answer 503 `actor.capacity.spent` until midnight UTC.
++- **Budgets are hard:**
++ - a budget above the tier is refused, never lowered;
++ - a task whose cheapest capable agent would cost more than its budget fails before running;
++ - an adapter stops before a call when nothing is left.
++- **Billing:** OpenVibe.Billing (T5) is not in the loop yet. Every cent is the free allowance's, the operator pays the providers with keys in `/etc/openvibe/actor.env`, and `cost.free_allowance_used` says so.
++
++## Owns
++
++- Tasks, their event logs and daily spend (`migrations/0001_tasks.sql`). Tasks are kept 30 days, then pruned with their events. Nothing stored is a credential, a key or a token.
++- The agent catalog and its rate cards; the routing policy over them (placement itself is `openvibe-sdk/placement`).
++
++## Does not own
++
++- Coding: **OpenVibe.Codes** (the coding-agent harness).
++- Execution sandboxes, browsers and desktops: **OpenVibe.Run** / **OpenVibe.Node** (next).
++- Accounts, apps and grants: **OpenVibe.Network** and **OpenVibe.Services**.
++- The tools the runtime uses: **OpenVibe.Tools**. Search: **OpenVibe.Search**.
++
++## What comes next
++
++1. Browser and desktop agents on OpenVibe's own cheap servers (Run workers with a headless browser and a virtual desktop), watchable live and taken over by the owner.
++2. Coding through OpenVibe.Codes on hosted Run sandboxes.
++3. More agent platforms behind the same router, bring-your-own keys through OpenVibe.AI, and paid tiers through Billing.
++4. Personal agents (`agt_` principals on the Network): memory people control, schedules and Events triggers, an approval inbox, reachable from Chat and the Frame.
++5. Account export and deletion (ADR-033). Until then, tasks expire after 30 days.
++
++## Configuration
++
++See [.env.example](.env.example). Required in production:
++- `OV_OAUTH_CLIENT_SECRET` (the `actor` principal; `server/setup/service-principal.js create actor` on the Network writes it);
++- `BASE_URL`, `DATABASE_URL` and `DATABASE_DIRECT_URL`;
++- at least one agent: `ACTOR_DEEPSEEK_API_KEY`, `ACTOR_OPENAI_API_KEY`, or `ACTOR_LOCAL_LLM_URL` + `ACTOR_LOCAL_LLM_MODEL`.
++
++Readiness requires the database and at least one available agent.
++
++## Deploy (for the lead)
++
++- **Deploy:** `sudo ovhost deploy actor` on the host (git checkout at `/opt/openvibe.actor`, unit `openvibe-actor.service` on 127.0.0.1:4950, env `/etc/openvibe/actor.env`, database `ov_actor` on the data role).
++- **nginx:** [deploy/nginx/openvibe.actor.conf](deploy/nginx/openvibe.actor.conf), installed with `ov-vhost-install`. A task's event stream is unbuffered.
++- **Rollback:** ovhost puts the previous sha back by itself when `/api/ready` does not answer after the restart.
++- **A stop:** running tasks are stopped and recorded as `actor.task.interrupted`. They are never re-run behind the person's back.
++
++## Development
++
++```bash
++npm install
++fnm exec --using=22 npm test # every test/*.test.js on temp PGlite databases, a mock Network and stand-in providers
++fnm exec --using=22 npm run dev # http://localhost:4950 (agents without keys are listed as unavailable)
++```
++
++Tests:
++- [test/tasks.test.js](test/tasks.test.js) runs a task end to end through the real adapters against stand-in providers, Tools and Search. It covers routing per class and mode, the cross-family check, hand-off on an error or a failed check, private mode never sending the text outside, hard budgets and the free tier, idempotency, cancel, the live stream, visibility, cross-site writes, and app tokens with capabilities.
++- The other files cover sign-in (PKCE, state, next=), discovery, the released contracts manifest matching the routes, per-caller limits, readiness, secrets never stored or logged, Actor never fetching a URL from a task, and the home page's size budgets.
++
++## Security (threat notes)
++
++Reporting a vulnerability: [SECURITY.md](SECURITY.md).
++
++- Session tokens are httpOnly cookies; a FedCM assertion or an app or service token is never a session.
++- Provider keys live only in the env file and in outbound `Authorization` headers to the providers; OpenVibe services get no provider key.
++- A model's links in an answer are rendered `rel="nofollow ugc noopener"`; all answer text is escaped first (a small Markdown subset).
++- Tasks are people's words: request bodies are never logged.
++
++---
++
++Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
++
++<!-- versions:start -->
++- openvibe-contracts: v0.115.0
++- openvibe-sdk: v0.35.2
++- openvibe-shared: v2.13.3
++<!-- versions:end -->
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..49b94e7
---- /dev/null
++++ b/SECURITY.md
@@@ -0,0 +1,21 @@
++# 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.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.
++
++## Supported versions
++
++Only the current `main` branch, which is what runs in production, receives fixes.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..986928f
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,52 @@
++{
++ "repository": "OpenVibers/OpenVibe.Actor",
++ "stage": "alpha",
++ "code": true,
++ "wave": "W20",
++ "plan": "OpenVibe Updated Plan Based On Codebase (T17, owner direction 2026-10-08: Actor is OpenVibe's own general agent and an OpenRouter for agents)",
++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
++ "port": 4950,
++ "domain": "openvibe.actor",
++ "contracts": "openvibe-contracts v0.115.0",
++ "sdk": "openvibe-sdk v0.35.2",
++ "shared": "openvibe-shared v2.13.3",
++ "packages": [
++ "openvibe-contracts v0.115.0",
++ "openvibe-sdk v0.35.2",
++ "openvibe-shared v2.13.3"
++ ],
++ "summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
++ "works": [
++ "The task router: POST /api/v1/tasks with modes (cheapest, balanced, best, fastest, private) and hard budgets; the explanation (platform.placement-result@1) on every task",
++ "The OpenVibe runtime: a DeepSeek model whose tools are OpenVibe.Search, OpenVibe.Tools' catalog and run API, and the Tools page reader",
++ "OpenAI's Responses agent with web search as a routed agent platform",
++ "Private mode on the open model on OpenVibe's own server; the task text never leaves OpenVibe",
++ "A cross-family check on every answer; hand-off to the next agent on an error or a failed check",
++ "Live progress over server-sent events (actor.task-event@1), cancel, idempotency keys",
++ "Sign-in with OpenVibe.Network (PKCE); people's own tokens and app tokens with actor.task.* capabilities",
++ "The public agent catalog with rate cards (GET /api/v1/agents) and a dry-run router (POST /api/v1/route)",
++ "Free tier per person and an operator daily ceiling; costs metered per call at the published rate cards",
++ "Pages: home with the task box, your tasks, a task's live page, the agents, the API reference, updates; robots, sitemap, llms.txt"
++ ],
++ "notYet": [
++ "Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
++ "Coding tasks through OpenVibe.Codes on hosted sandboxes (listed as unavailable with the reason)",
++ "More agent platforms, bring-your-own keys through OpenVibe.AI, paid tiers through OpenVibe.Billing",
++ "Personal agents: memory, schedules, Events triggers, approvals, reach from Chat and the Frame",
++ "Webhooks on task state changes",
++ "Account export and deletion (ADR-033); tasks expire after 30 days meanwhile"
++ ],
++ "capabilitiesRegistered": true,
++ "serviceManifestRegistered": true,
++ "deployed": true,
++ "placeholderInSites": false,
++ "features": [
++ "agent router (OpenRouter for agents)",
++ "OpenVibe runtime acting through OpenVibe services",
++ "other platforms' agents",
++ "private mode on OpenVibe hardware",
++ "cross-family answer checks",
++ "live task progress"
++ ],
++ "updated": "2026-10-08"
++}
ddiff --git a/deploy/nginx/openvibe.actor.conf b/deploy/nginx/openvibe.actor.conf
nnew file mode 100644
iindex 0000000..0c9f9a0
---- /dev/null
++++ b/deploy/nginx/openvibe.actor.conf
@@@ -0,0 +1,122 @@
++## ═══════════════════════════════════════════════════════════════
++## openvibe.actor — Nginx configuration (REFERENCE)
++##
++## Deployed to /etc/nginx/sites-available/openvibe.actor.conf (ov-vhost-install) — keep them in sync and reload
++## nginx after changing it. The Node app (port 4950, systemd unit openvibe-actor) renders every page and serves the
++## task API; nginx terminates TLS, redirects www → apex, rate-limits sign-in and the API, keeps a task's live stream
++## unbuffered and compresses text.
++##
++## No proxy_cache: the app decides cacheability per response (public for the pages rendered for anonymous
++## visitors; private, no-store for everything personal). Never log request bodies here: tasks are people's words.
++## ═══════════════════════════════════════════════════════════════
++
++limit_req_zone $binary_remote_addr zone=ovactor_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=ovactor_api:10m rate=5r/s;
++limit_req_zone $binary_remote_addr zone=ovactor_forms:10m rate=1r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.actor www.openvibe.actor;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.actor$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.actor;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.actor/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.actor/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ return 301 https://openvibe.actor$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.actor;
++
++ ssl_certificate /etc/letsencrypt/live/openvibe.actor/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.actor/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++
++ access_log /var/log/nginx/openvibe.actor.access.log;
++ error_log /var/log/nginx/openvibe.actor.error.log;
++
++ # A task is at most 20000 characters; the API's JSON cap is 64 KB.
++ client_max_body_size 128k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # ── Network SSO session endpoints ───────────────────────
++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
++ location = /auth/me {
++ limit_req zone=ovactor_api burst=30 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ location /auth/ {
++ limit_req zone=ovactor_auth burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ # ── A task's live stream: server-sent events, never buffered, held open between heartbeats ──
++ location ~ ^/api/v1/tasks/[^/]+/events$ {
++ limit_req zone=ovactor_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_buffering off;
++ proxy_cache off;
++ proxy_read_timeout 300s;
++ gzip off;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ # ── API ─────────────────────────────────────────────────
++ location /api/ {
++ limit_req zone=ovactor_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ # ── The task form and cancel (POST /tasks, /tasks/:id/cancel) ──
++ location ~ ^/tasks {
++ limit_req zone=ovactor_forms burst=10 nodelay;
++ limit_req_status 429;
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4950/metrics) ──
++ location = /metrics { return 404; }
++
++ # ── Pages, docs, robots.txt, sitemap, static assets ─────
++ location / {
++ proxy_pass http://127.0.0.1:4950;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-actor.service b/deploy/systemd/openvibe-actor.service
nnew file mode 100644
iindex 0000000..f368595
---- /dev/null
++++ b/deploy/systemd/openvibe-actor.service
@@@ -0,0 +1,30 @@
++[Unit]
++Description=OpenVibe.Actor — OpenVibe's own agent and the router for agent work (tasks, the agent catalog, the live stream)
++# Network (sign-in, JWKS) should be up first, but Actor degrades honestly without it: the pages and the agent
++# catalog serve; sign-in and the task API wait for the signing key (see /api/ready).
++After=network.target postgresql.service pgbouncer.service valkey-server.service
++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.actor
++EnvironmentFile=/etc/openvibe/actor.env
++Environment=NODE_ENV=production
++Environment=PORT=4950
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.actor/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++# Running tasks are stopped and recorded as interrupted within the 10 s drain.
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++# Actor's own database is PostgreSQL on the host's data role; the code tree stays read-only.
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/migrations/0001_tasks.sql b/migrations/0001_tasks.sql
nnew file mode 100644
iindex 0000000..4a53fb1
---- /dev/null
++++ b/migrations/0001_tasks.sql
@@@ -0,0 +1,48 @@
++-- phase: expand
++-- OpenVibe.Actor: tasks (platform.task@1), each task's event log (actor.task-event@1) and what each requester spent
++-- per UTC day (budgets and the free allowance; requester '*' is everyone, the operator's daily ceiling). Nothing here
++-- is ever a credential, a key or a token.
++
++CREATE TABLE tasks (
++ id text COLLATE "C" PRIMARY KEY, -- tsk_<ULID>
++ requester text COLLATE "C" NOT NULL, -- user:usr_… | app:app_… | agent:agt_… | service:<slug>
++ project_id text COLLATE "C", -- prj_<ULID> when the token named one
++ task text NOT NULL,
++ mode text COLLATE "C" NOT NULL,
++ agent text COLLATE "C", -- the agent system the caller pinned, if any
++ budget_task double precision NOT NULL,
++ budget_day double precision NOT NULL,
++ state text COLLATE "C" NOT NULL,
++ result jsonb,
++ error_code text COLLATE "C",
++ error_detail text,
++ cost_usd double precision,
++ free_usd double precision,
++ explanation jsonb,
++ cancel_at text COLLATE "C",
++ cancel_by text COLLATE "C",
++ last_seq integer NOT NULL DEFAULT 0,
++ idem_key text COLLATE "C",
++ idem_hash text COLLATE "C",
++ created_at text COLLATE "C" NOT NULL,
++ finished_at text COLLATE "C"
++);
++CREATE INDEX tasks_by_requester ON tasks (requester, id DESC);
++CREATE UNIQUE INDEX tasks_idempotency ON tasks (requester, idem_key) WHERE idem_key IS NOT NULL;
++CREATE INDEX tasks_open ON tasks (state) WHERE state IN ('queued', 'running', 'verifying');
++CREATE INDEX tasks_created ON tasks (created_at);
++
++CREATE TABLE task_events (
++ task_id text COLLATE "C" NOT NULL REFERENCES tasks (id) ON DELETE CASCADE,
++ seq integer NOT NULL,
++ body jsonb NOT NULL,
++ PRIMARY KEY (task_id, seq)
++);
++
++CREATE TABLE spend_daily (
++ requester text COLLATE "C" NOT NULL,
++ day text COLLATE "C" NOT NULL, -- YYYY-MM-DD, UTC
++ usd double precision NOT NULL DEFAULT 0,
++ tasks integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (requester, day)
++);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..1b3d1be
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1404 @@
++{
++ "name": "openvibe-actor",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-actor",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/express-rate-limit": {
++ "version": "7.5.1",
++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 16"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/express-rate-limit"
++ },
++ "peerDependencies": {
++ "express": ">= 4.11"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/helmet": {
++ "version": "8.3.0",
++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=18.0.0"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/EvanHahn"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.115.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
++ "integrity": "sha512-2si6od4+RLgZEIAuWMmTrLZL65TzvCwB/sKoe24vSzC9+sNPt2RXWGXDCwE2fB90vmaRvX6pI+DQyu18QN2f0Q==",
++ "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.35.2",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
++ "integrity": "sha512-HudKZJK3lZ9i0+yRdMrz9pJmIrYNTN8AFqbHFCz5jDCF68CfZwUJQyhE61pHODzGRyTvUi4DtAzkd0WEzIWx/g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "2.13.3",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
++ "integrity": "sha512-pKRAMWWh5iJJwgOY3Hr0N+6yRa0j7FASDozeVz9LBxsJ65gMLXXHfdhrWpGuke42tzRt/234ATHk9vQdTmWNNA==",
++ "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.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..d9f3bdc
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,41 @@
++{
++ "name": "openvibe-actor",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Actor: OpenVibe's own agent and the router for agent work. A task in plain words goes to the best agent system for it (OpenVibe's runtime acting through OpenVibe services, other platforms' agents, an open model on OpenVibe hardware), a second model family checks the answer, and the task records its cost and why. Like OpenRouter, for agents.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "test:pg": "ACTOR_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe",
++ "ai-agent",
++ "agent-router",
++ "openrouter-for-agents",
++ "computer-use"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Actor",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "express-rate-limit": "^7.5.0",
++ "helmet": "^8.1.0",
++ "iovalkey": "^0.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.115.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.13.3",
++ "pg": "^8.23.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/public/css/actor.css b/public/css/actor.css
nnew file mode 100644
iindex 0000000..1ae243c
---- /dev/null
++++ b/public/css/actor.css
@@@ -0,0 +1,105 @@
++/* OpenVibe.Actor — theme-token driven (openvibe-shared presets). Every page works without JavaScript. */
++:root { color-scheme: dark; }
++* { box-sizing: border-box; }
++body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
++a { color: var(--accent-light, #60a5fa); }
++a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.skip { position: absolute; left: -9999px; }
++.skip:focus { left: 16px; top: 8px; background: var(--bg-card, #131c2e); padding: 8px 12px; z-index: 10; }
++.account-bar { max-width: 1080px; margin: 0 auto; padding: 6px 16px 0; text-align: right; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.page { max-width: 1080px; margin: 0 auto; padding: 16px 16px 64px; min-height: 60vh; }
++h1 { font-size: 1.8rem; line-height: 1.2; margin: 1rem 0 .6rem; overflow-wrap: anywhere; }
++h2 { font-size: 1.3rem; margin: 1.8rem 0 .5rem; }
++h3 { font-size: 1.08rem; margin: 1.2rem 0 .4rem; }
++.muted { color: var(--text-muted, #7386a3); }
++.small { font-size: .85rem; }
++.crumbs { font-size: .9rem; color: var(--text-muted, #7386a3); margin: 8px 0; overflow-wrap: anywhere; }
++code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .88em; }
++code { overflow-wrap: anywhere; }
++pre { background: var(--bg-secondary, #0f1729); border: 1px solid var(--border, #1f2d47); border-radius: 8px; padding: 10px 12px; overflow-x: auto; white-space: pre; }
++pre code { overflow-wrap: normal; }
++.table-wrap { overflow-x: auto; }
++table { border-collapse: collapse; width: 100%; font-size: .92rem; }
++th, td { text-align: left; vertical-align: top; padding: 7px 9px; border-bottom: 1px solid var(--border, #1f2d47); }
++th { color: var(--text-secondary, #96a7c2); font-weight: 600; }
++.badge { display: inline-block; font-size: .75rem; padding: 0 8px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; line-height: 1.6; }
++.badge.ok { border-color: var(--success, #22c55e); color: var(--success, #22c55e); }
++.badge.info { border-color: var(--info, #38bdf8); color: var(--info, #38bdf8); }
++.badge.warn { border-color: var(--warning, #f59e0b); color: var(--warning, #f59e0b); }
++.badge.bad { border-color: var(--error, #ef4444); color: var(--error, #ef4444); }
++.notice { border-left: 3px solid var(--info, #38bdf8); padding: 8px 12px; margin: 12px 0; background: var(--bg-card, #131c2e); border-radius: 0 8px 8px 0; }
++.notice.ok { border-left-color: var(--success, #22c55e); }
++.notice.warn { border-left-color: var(--warning, #f59e0b); }
++.notice.bad { border-left-color: var(--error, #ef4444); }
++button { font: inherit; cursor: pointer; }
++form.inline { display: inline; }
++.prose { max-width: 820px; overflow-wrap: anywhere; }
++.prose table { margin: .6rem 0; }
++
++/* The home page's showcase sections (openvibe-shared/showcase.css) sit inside .page, which has its own gutter. */
++.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
++.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
++button.sc-btn { background: transparent; }
++button.sc-btn.sc-primary { border: 0; }
++
++/* ── The router card (the hero's aside): one sample decision ── */
++.router-card { margin: 0; padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: linear-gradient(180deg, var(--bg-secondary, #101828), var(--bg-primary, #0a0f1c)); }
++.router-card figcaption { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
++.router-card figcaption b { font-size: 15px; line-height: 1.4; }
++.router-card ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
++.router-card li { display: grid; grid-template-columns: 1fr auto; gap: 2px 12px; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); }
++.router-card li.pick { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
++.router-card li.pick .rc-name::after { content: " · picked"; color: var(--accent-light, #60a5fa); font-weight: 700; font-size: 12px; }
++.rc-name { font-weight: 700; font-size: 14px; }
++.rc-cost { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--text-secondary, #96a7c2); }
++.rc-why { grid-column: 1 / -1; font-size: 12.5px; color: var(--text-muted, #7386a3); }
++.rc-foot { margin: 10px 0 0; font-size: 12.5px; color: var(--text-muted, #7386a3); }
++
++/* ── The task box ── */
++.task-box { display: flex; flex-direction: column; gap: 12px; padding: 18px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); max-width: 860px; }
++.task-box-lead { margin: 0; font-size: 1.05rem; }
++.task-label { font-weight: 700; }
++.task-box textarea { width: 100%; min-height: 110px; resize: vertical; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; line-height: 1.5; }
++.task-box textarea:focus { border-color: var(--accent, #3b82f6); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent, #3b82f6) 25%, transparent); }
++.modes { border: 0; padding: 0; margin: 0; display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
++.modes legend { font-weight: 700; margin-bottom: 6px; padding: 0; }
++.mode { position: relative; display: block; cursor: pointer; }
++.mode input { position: absolute; opacity: 0; inset: 0; }
++.mode span { display: flex; flex-direction: column; gap: 2px; height: 100%; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); transition: border-color .15s, background .15s; }
++.mode b { font-size: 14px; }
++.mode small { font-size: 12.5px; line-height: 1.4; color: var(--text-muted, #7386a3); }
++.mode input:checked + span { border-color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); }
++.mode input:focus-visible + span { outline: 3px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.task-actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
++.examples { margin: 0; padding: 0; list-style: none; display: grid; gap: 6px; }
++.examples li { padding: 8px 12px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); font-size: .94rem; }
++
++/* ── The agents ── */
++.agent-cards { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
++.agent-card { padding: 16px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); }
++.agent-card.off { opacity: .78; }
++.agent-card header { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
++.agent-card p { margin: .5rem 0 0; font-size: 14px; line-height: 1.5; }
++
++/* ── One task ── */
++.task-text { font-size: 1.45rem; font-weight: 700; white-space: pre-wrap; }
++.task-meta { color: var(--text-secondary, #96a7c2); font-size: .92rem; display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; }
++.answer { margin-top: 1.4rem; padding: 18px; border-radius: 16px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-secondary, #101828); }
++.answer > h2 { margin-top: 0; }
++.sources { padding-left: 18px; font-size: .92rem; overflow-wrap: anywhere; }
++.steps-log { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.step { display: grid; grid-template-columns: 76px 1fr; gap: 10px; padding: 8px 10px; border-radius: 10px; background: var(--bg-card, #131c2e); font-size: .92rem; animation: step-in .25s ease-out; }
++.step-k { font-size: 12px; font-weight: 700; letter-spacing: .4px; text-transform: uppercase; color: var(--text-muted, #7386a3); padding-top: 2px; }
++.step-v { overflow-wrap: anywhere; white-space: pre-wrap; }
++.step-check .step-k { color: var(--success, #22c55e); }
++.step.err .step-k { color: var(--warning, #f59e0b); }
++.step-plan .step-k, .step-handoff .step-k { color: var(--accent-light, #60a5fa); }
++@keyframes step-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
++.why { margin-top: 1.4rem; border: 1px solid var(--border, #1f2d47); border-radius: 12px; padding: 10px 14px; }
++.why summary { cursor: pointer; font-weight: 700; }
++
++@media (max-width: 640px) {
++ .step { grid-template-columns: 1fr; gap: 2px; }
++ .task-text { font-size: 1.2rem; }
++}
++@media (prefers-reduced-motion: reduce) { .step { animation: none; } .mode span { transition: none; } }
ddiff --git a/public/js/task.js b/public/js/task.js
nnew file mode 100644
iindex 0000000..92f07c9
---- /dev/null
++++ b/public/js/task.js
@@@ -0,0 +1,51 @@
++/* A running task's page: follows its live stream (GET /api/v1/tasks/:id/events, actor.task-event@1) and adds each step
++ * as it happens; when the task ends the page reloads to show the answer as the server renders it. Without this script
++ * the page refreshes itself every few seconds (a <noscript> refresh), so nothing depends on it. */
++(function () {
++ 'use strict';
++ function start() {
++ var el = document.querySelector('article.task[data-open="1"]');
++ if (!el || !window.EventSource || el.dataset.live) return;
++ el.dataset.live = '1';
++ var id = el.getAttribute('data-task');
++ var after = el.getAttribute('data-last') || '0';
++ var log = document.getElementById('steps-log');
++ var cost = document.getElementById('task-cost');
++ var LABEL = { plan: 'Plan', text: 'Says', tool_call: 'Uses', tool_result: 'Got', check: 'Check', handoff: 'Hands on' };
++
++ function step(e) {
++ var li = document.createElement('li');
++ li.className = 'step step-' + e.step + (e.is_error ? ' err' : '');
++ var k = document.createElement('span');
++ k.className = 'step-k';
++ k.textContent = LABEL[e.step] || e.step;
++ var v = document.createElement('span');
++ v.className = 'step-v';
++ if (e.tool) {
++ var c = document.createElement('code');
++ c.textContent = e.tool;
++ v.appendChild(c);
++ v.appendChild(document.createTextNode(' ' + (e.step === 'tool_result' ? String(e.text || '').slice(0, 240) : (e.input || ''))));
++ } else {
++ v.textContent = e.text || '';
++ }
++ li.appendChild(k);
++ li.appendChild(v);
++ log.appendChild(li);
++ }
++
++ var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(after));
++ src.addEventListener('output', function (m) { try { step(JSON.parse(m.data)); } catch (err) { /* a malformed event is skipped */ } });
++ src.addEventListener('state', function () {
++ fetch('/api/v1/tasks/' + encodeURIComponent(id), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (t) {
++ if (t && t.cost && cost) cost.textContent = '$' + (t.cost.usd < 0.01 ? t.cost.usd.toFixed(4) : t.cost.usd.toFixed(2));
++ }).catch(function () {});
++ });
++ src.addEventListener('end', function () { src.close(); window.location.reload(); });
++ // The page's own refresh is the fallback when the stream cannot stay open.
++ src.onerror = function () { if (src.readyState === 2) setTimeout(function () { window.location.reload(); }, 4000); };
++ window.addEventListener('pagehide', function () { src.close(); }, { once: true });
++ }
++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
++ document.addEventListener('ov:boost:load', start);
++})();
ddiff --git a/server/agents/adapters.js b/server/agents/adapters.js
nnew file mode 100644
iindex 0000000..92d6e46
---- /dev/null
++++ b/server/agents/adapters.js
@@@ -0,0 +1,103 @@
++'use strict';
++
++/**
++ * One adapter per agent system (ADR-044 §2): run(task) → { text, sources, model }. Each reports its steps through
++ * `emit` (actor.task-event@1 output events) and every model call's cost through `meter(usd)` as it happens, so the
++ * task's cost and the person's spend are true even when a run is cancelled half-way. `budgetLeft()` is checked
++ * before every paid call; an adapter that would go over stops and says so.
++ *
++ * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls
++ * openai-agent OpenAI's Responses agent with its hosted web search
++ * open-model the open model on OpenVibe's own server, one call, no tools
++ */
++const { chatClient, responsesAgent, ProviderError } = require('./providers');
++const { createOpenVibeTools } = require('./tools');
++
++const MAX_TURNS = 10;
++
++const today = () => new Date().toISOString().slice(0, 10);
++
++const RUNTIME_SYSTEM = () => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.
++Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.
++Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.
++Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;
++
++const AGENT_INSTRUCTIONS = () => `You are working for OpenVibe Actor on a person's task. Today is ${today()}.
++Search the web when the task needs anything current, and cite your sources inline as links. Answer in Markdown, short and direct, then list the sources you used.
++Never invent facts, numbers or links; if something cannot be found, say so.`;
++
++const summarize = (args) => {
++ const a = args || {};
++ const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : JSON.stringify(a));
++ return String(v).slice(0, 300);
++};
++
++function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride }) {
++ const p = config.providers;
++ const ovTools = toolsOverride || createOpenVibeTools({ config, fetchImpl, log });
++
++ const deepseek = p.deepseek.apiKey ? chatClient({ baseUrl: p.deepseek.baseUrl, apiKey: p.deepseek.apiKey, model: p.deepseek.model, rates: rates.deepseek, fetchImpl }) : null;
++ const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
++ const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
++
++ async function runtime({ task, signal, emit, meter, budgetLeft }) {
++ if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
++ const tools = await ovTools.definitions({ signal });
++ const messages = [{ role: 'system', content: RUNTIME_SYSTEM() }, { role: 'user', content: task }];
++ let last = '';
++ for (let turn = 1; turn <= MAX_TURNS; turn++) {
++ if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
++ const finalTurn = turn === MAX_TURNS;
++ const r = await deepseek({ messages, tools: finalTurn ? null : tools, maxTokens: 1800, signal });
++ await meter(r.cost_usd);
++ messages.push(r.message);
++ if (r.text && r.text.trim()) { last = r.text.trim(); }
++ if (!r.toolCalls.length) return { text: last, sources: [], model: r.model };
++ if (r.text && r.text.trim()) await emit({ step: 'text', text: r.text.trim().slice(0, 2000) });
++ for (const call of r.toolCalls) {
++ const name = call.function && call.function.name;
++ let args = {};
++ try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
++ await emit({ step: 'tool_call', tool: name, input: summarize(args) });
++ const out = await ovTools.call(name, args, { signal });
++ const isError = /^\{"error"/.test(out);
++ await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
++ messages.push({ role: 'tool', tool_call_id: call.id, content: out });
++ }
++ }
++ if (!last) throw new ProviderError('the runtime used all its turns without an answer', { code: 'actor.agent.no_answer' });
++ return { text: last, sources: [], model: p.deepseek.model };
++ }
++
++ async function openai({ task, mode, signal, emit, meter, budgetLeft }) {
++ if (!openaiAgent) throw new ProviderError('no OpenAI key is configured', { code: 'actor.agent.unavailable' });
++ if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget', { code: 'actor.budget.exceeded' });
++ await emit({ step: 'text', text: 'Searching the web…' });
++ const r = await openaiAgent({ instructions: AGENT_INSTRUCTIONS(), input: task, effort: mode === 'best' ? 'medium' : 'low', signal });
++ await meter(r.cost_usd);
++ for (const s of r.searches) await emit({ step: 'tool_call', tool: s.kind === 'search' ? 'web_search' : `web_${s.kind}`, input: String(s.query || '').slice(0, 300) });
++ if (!r.text) throw new ProviderError('the agent finished without an answer', { code: 'actor.agent.no_answer' });
++ return { text: r.text, sources: r.sources, model: r.model };
++ }
++
++ async function openModel({ task, signal, meter }) {
++ if (!local) throw new ProviderError('no open model is running on this server', { code: 'actor.agent.unavailable' });
++ const r = await local({ messages: [{ role: 'system', content: `You are a helpful assistant. Today is ${today()}. Answer briefly in Markdown. If you are not sure, say so.` }, { role: 'user', content: task }], maxTokens: 700, signal });
++ await meter(r.cost_usd);
++ if (!r.text.trim()) throw new ProviderError('the open model gave no answer', { code: 'actor.agent.no_answer' });
++ return { text: r.text.trim(), sources: [], model: r.model };
++ }
++
++ const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel };
++
++ /** A cheap model of another family to classify and to check results (null when none is configured). */
++ const checkers = {
++ deepseek: deepseek,
++ openai: p.openai.apiKey ? chatClient({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.checkModel || 'gpt-5-nano', rates: rates.openaiCheck, fetchImpl }) : null,
++ local,
++ };
++
++ return { run, checkers, tools: ovTools };
++}
++
++module.exports = { createAdapters, MAX_TURNS };
ddiff --git a/server/agents/catalog.js b/server/agents/catalog.js
nnew file mode 100644
iindex 0000000..372ac0a
---- /dev/null
++++ b/server/agents/catalog.js
@@@ -0,0 +1,157 @@
++'use strict';
++
++/**
++ * The agent systems Actor routes a task to (ADR-044 §2): what each can do, its trust class, its rate card and whether
++ * it can take a task now. GET /api/v1/agents is this list (actor.agent-list-result@1); the router turns each into an
++ * openvibe-sdk/placement offer (toOffer) so the planner picks by capability first, then by the mode's objective.
++ *
++ * Rate cards are typed in from the provider's own price page by a person, with the page and the day it was checked;
++ * a model never writes them. DeepSeek bills peak hours at these prices and off-peak at half; Actor charges the peak
++ * price so a task never costs more than it was told.
++ *
++ * Task classes (server/agents/classify.js): answer (write or explain from what a model knows), lookup (facts about a
++ * domain, address or site: DNS, mail, certificates, headers, ownership), research (read given pages or OpenVibe's own
++ * content), web (needs a live web search: news, prices, anything recent), code (write or change code).
++ */
++
++const VERIFIED = '2026-10-08';
++const DEEPSEEK_PRICES = 'https://api-docs.deepseek.com/quick_start/pricing';
++const OPENAI_PRICES = 'https://developers.openai.com/api/docs/pricing';
++
++/**
++ * Typical tokens one task of each class uses on an agent, from the first runs (2026-10-08): the planner's cost
++ * estimate is these at the rate card. The real cost is metered per call and is what the task is charged.
++ */
++const TYPICAL = {
++ 'openvibe-runtime': { in: 9000, out: 900, calls: 0, latencyMs: 25_000 },
++ 'openai-agent': { in: 11000, out: 700, calls: 2, latencyMs: 30_000 },
++ 'open-model': { in: 800, out: 400, calls: 0, latencyMs: 45_000 },
++ codes: { in: 40000, out: 4000, calls: 0, latencyMs: 300_000 },
++};
++
++const AGENTS = [
++ {
++ id: 'openvibe-runtime',
++ name: 'OpenVibe runtime',
++ kind: 'openvibe-runtime',
++ provider: 'OpenVibe',
++ providerKey: 'deepseek',
++ // The runtime is OpenVibe's code, but its model runs at DeepSeek: the task's text leaves OpenVibe.
++ trust: 'external',
++ quality: 'standard',
++ can: ['task:answer', 'task:lookup', 'task:research', 'tool:openvibe-search', 'tool:openvibe-tools'],
++ rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: DEEPSEEK_PRICES, verified_at: VERIFIED },
++ blurb: 'OpenVibe\'s own agent: a DeepSeek model that acts through OpenVibe services — search across the network, 160+ tools (DNS, mail, certificates, headers, conversions) and a page reader.',
++ },
++ {
++ id: 'openai-agent',
++ name: 'OpenAI agent',
++ kind: 'agent-platform',
++ provider: 'OpenAI',
++ providerKey: 'openai',
++ trust: 'external',
++ quality: 'high',
++ can: ['task:answer', 'task:research', 'task:web', 'tool:web-search'],
++ rate_card: { input_usd_per_mtok: 0.25, output_usd_per_mtok: 2.00, per_call_usd: 0.01, source: OPENAI_PRICES, verified_at: VERIFIED },
++ blurb: 'OpenAI\'s Responses agent with live web search: for anything recent, with sources. Searches cost one cent each on top of tokens.',
++ },
++ {
++ id: 'open-model',
++ name: 'Open model on OpenVibe',
++ kind: 'open-model',
++ provider: 'OpenVibe',
++ providerKey: 'local',
++ trust: 'first-party',
++ quality: 'basic',
++ can: ['task:answer'],
++ rate_card: { input_usd_per_mtok: 0, output_usd_per_mtok: 0, source: 'https://openvibe.actor/agents#open-model', verified_at: VERIFIED },
++ blurb: 'A small open model on OpenVibe\'s own server: nothing leaves OpenVibe. Private mode uses only this. Simple answers, no tools.',
++ },
++ {
++ id: 'codes',
++ name: 'OpenVibe.Codes',
++ kind: 'codes',
++ provider: 'OpenVibe',
++ providerKey: 'codes',
++ trust: 'first-party',
++ quality: 'high',
++ can: ['task:code'],
++ rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: 'https://openvibe.codes/harnesses', verified_at: VERIFIED },
++ blurb: 'Coding work goes to OpenVibe.Codes, the coding-agent harness (Claude Code, Codex, OpenCode, DeepSeek and more). Hosted coding runs come with OpenVibe.Run; today, run openvibe-codes on your own machine.',
++ },
++];
++
++/** Is the agent usable with this configuration? { available, reason }. */
++function availability(agent, config) {
++ const p = config.providers || {};
++ if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
++ if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
++ if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
++ if (agent.providerKey === 'codes') return { available: false, reason: 'hosted coding runs need OpenVibe.Run sandboxes, which are not open yet; run openvibe-codes on your own machine (openvibe.codes/start)' };
++ return { available: false, reason: 'no adapter' };
++}
++
++/** The model an agent runs with this configuration (for the listing and the task's explanation). */
++function modelOf(agent, config) {
++ const p = (config.providers || {})[agent.providerKey];
++ return p && p.model ? p.model : undefined;
++}
++
++/** What one task of this agent typically costs, at its rate card (USD). */
++function typicalCost(agentId) {
++ const a = AGENTS.find((x) => x.id === agentId);
++ const t = TYPICAL[agentId];
++ if (!a || !t) return 0;
++ const r = a.rate_card;
++ return (t.in * r.input_usd_per_mtok + t.out * r.output_usd_per_mtok) / 1e6 + t.calls * (r.per_call_usd || 0);
++}
++
++/** GET /api/v1/agents: actor.agent-list-result@1. */
++function list(config) {
++ return AGENTS.map((a) => {
++ const av = availability(a, config);
++ const row = { id: a.id, name: a.name, kind: a.kind, provider: a.provider, trust: a.trust, can: [...a.can], rate_card: { ...a.rate_card }, typical_latency_ms: TYPICAL[a.id].latencyMs, available: av.available };
++ const model = modelOf(a, config);
++ if (model) row.model = model;
++ if (av.reason) row.reason = av.reason;
++ return row;
++ });
++}
++
++/**
++ * One platform.resource-offer@1 per agent for openvibe-sdk/placement. `quality:<tier>` is an offer capability so
++ * best mode can require it; an unavailable agent is health down with its reason kept for the explanation.
++ */
++function toOffer(agent, config, { now = Date.now(), measured = {} } = {}) {
++ const av = availability(agent, config);
++ const t = TYPICAL[agent.id];
++ const quality = agent.quality === 'high' ? ['quality:high', 'quality:standard'] : agent.quality === 'standard' ? ['quality:standard'] : [];
++ return {
++ offer_id: agent.id,
++ kind: 'agent',
++ provider: agent.provider,
++ region: 'global',
++ trust: agent.trust,
++ capabilities: [...agent.can, ...quality],
++ capacity: {},
++ health: av.available ? { status: 'up' } : { status: 'down', reason: av.reason },
++ pricing: { model: 'per-operation', unit: 'task', marginal_usd_per_unit: typicalCost(agent.id) },
++ latency_ms: { run_p95: measured[agent.id] || t.latencyMs },
++ updated_at: new Date(now).toISOString(),
++ };
++}
++
++const byId = (id) => AGENTS.find((a) => a.id === id) || null;
++
++/**
++ * What each provider call is priced at (USD per million tokens; cached input at its own rate; per web search): the
++ * rate cards above plus the cached-input prices from the same pages, and the checker model's own card.
++ */
++const RATES = {
++ deepseek: { input_usd_per_mtok: 0.30, cached_usd_per_mtok: 0.006, output_usd_per_mtok: 1.20 },
++ openai: { input_usd_per_mtok: 0.25, cached_usd_per_mtok: 0.025, output_usd_per_mtok: 2.00, per_call_usd: 0.01 },
++ openaiCheck: { input_usd_per_mtok: 0.05, cached_usd_per_mtok: 0.005, output_usd_per_mtok: 0.40 },
++ local: { input_usd_per_mtok: 0, output_usd_per_mtok: 0 },
++};
++
++module.exports = { AGENTS, TYPICAL, RATES, availability, list, toOffer, typicalCost, modelOf, byId, VERIFIED };
ddiff --git a/server/agents/check.js b/server/agents/check.js
nnew file mode 100644
iindex 0000000..00782fa
---- /dev/null
++++ b/server/agents/check.js
@@@ -0,0 +1,47 @@
++'use strict';
++
++/**
++ * The check between `running` and `succeeded` (ADR-044 §5): a result is read by a model of another family than the
++ * one that wrote it before the person gets it. DeepSeek's answers are checked by OpenAI's smallest model and OpenAI's
++ * by DeepSeek; in private mode only OpenVibe's own open model may read the task, so it checks its own answer and the
++ * explanation says so. A check that cannot run (no checker, a provider error) does not pass the result unchecked:
++ * the task fails with actor.check.unavailable.
++ *
++ * The bar is "does this answer the task, without invented or self-contradicting facts" — not taste. A refusal, an
++ * empty answer or one about something else fails.
++ */
++
++const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local' };
++const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'] };
++
++const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
++ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.
++ok is false when the answer is empty, refuses without a good reason, answers a different question, or states something clearly false.`;
++
++/** Which checker reads this agent's answer, and whether it is of another family. */
++function checkerFor(agentId, checkers, { mode }) {
++ const family = FAMILY[agentId];
++ const order = mode === 'private' ? ['local'] : (CHECKER_FOR[family] || []);
++ for (const f of order) if (checkers[f]) return { family: f, chat: checkers[f], cross: f !== family };
++ return null;
++}
++
++async function check({ task, answer, agentId, mode, checkers, signal }) {
++ const c = checkerFor(agentId, checkers, { mode });
++ if (!c) return { ok: false, unavailable: true, reason: 'no model is available to check this answer', cost_usd: 0 };
++ try {
++ const r = await c.chat({
++ messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: `Task:\n${String(task).slice(0, 6000)}\n\nAnswer:\n${String(answer).slice(0, 12000)}` }],
++ json: true, maxTokens: 120, signal,
++ });
++ let parsed = null;
++ try { parsed = JSON.parse(String(r.text || '').trim().replace(/^```(?:json)?|```$/g, '')); } catch { parsed = null; }
++ if (!parsed || typeof parsed.ok !== 'boolean') return { ok: false, unavailable: true, reason: 'the checker gave no verdict', cost_usd: r.cost_usd, by: c.family, cross: c.cross };
++ return { ok: parsed.ok, reason: String(parsed.reason || '').slice(0, 300), cost_usd: r.cost_usd, by: c.family, model: r.model, cross: c.cross };
++ } catch (err) {
++ if (signal && signal.aborted) throw err;
++ return { ok: false, unavailable: true, reason: `the check could not run (${(err && err.message) || err})`.slice(0, 300), cost_usd: 0, by: c.family, cross: c.cross };
++ }
++}
++
++module.exports = { check, checkerFor, FAMILY };
ddiff --git a/server/agents/classify.js b/server/agents/classify.js
nnew file mode 100644
iindex 0000000..23acebe
---- /dev/null
++++ b/server/agents/classify.js
@@@ -0,0 +1,64 @@
++'use strict';
++
++/**
++ * What kind of task this is, so the router knows what an agent must be able to do (a hard requirement, ADR-044 §4):
++ *
++ * code write, fix or review code → task:code
++ * web needs a live web search (recent, prices, news) → task:web
++ * lookup facts about a domain, address or site → task:lookup
++ * research read the pages it names, or OpenVibe's content → task:research
++ * answer write or explain from what a model knows → task:answer
++ *
++ * heuristic(task) is free and is what private mode and the public dry run use. classify() asks a cheap model when one
++ * is configured, and falls back to the heuristic on any failure; its cost is part of the task's.
++ */
++
++const CLASSES = ['answer', 'lookup', 'research', 'web', 'code'];
++
++const CODE = /\b(code|function|bug|stack ?trace|refactor|unit tests?|pull request|repo(sitory)?|compile|typescript|javascript|python|golang|rust|java|sql query|regex|script|api endpoint|implement)\b|```/i;
++const LOOKUP = /\b(dns|mx|spf|dkim|dmarc|whois|rdap|nameservers?|ssl|tls|certificate|headers?|redirects?|ip address|asn|geoip|reverse dns|ptr|ttl|robots\.txt|sitemap|uptime|ping|traceroute|port \d+|hosting provider|registrar)\b/i;
++const DOMAINISH = /\b(?:[a-z0-9-]+\.)+[a-z]{2,}\b|\b\d{1,3}(?:\.\d{1,3}){3}\b/i;
++const WEB = /\b(latest|today|tonight|yesterday|this week|this month|current(ly)?|right now|news|price of|stock|weather|score|election|released?|recent(ly)?|202\d|trending|search the web|look up online|compare prices)\b/i;
++const URL = /https?:\/\/\S+/i;
++const RESEARCH = /\b(summari[sz]e|read|according to|sources?|cite|citations?|brief|report on|research)\b/i;
++
++function heuristic(task) {
++ const t = String(task || '');
++ if (CODE.test(t)) return 'code';
++ if (LOOKUP.test(t) && DOMAINISH.test(t)) return 'lookup';
++ if (URL.test(t)) return 'research';
++ if (WEB.test(t)) return 'web';
++ if (RESEARCH.test(t)) return 'web';
++ return 'answer';
++}
++
++const SYSTEM = `You sort one task for an agent router. Reply with JSON only: {"class": "<one of answer|lookup|research|web|code>"}.
++answer: writing, explaining, advice, maths, anything a capable model can do from what it knows.
++lookup: facts about a specific domain, website, IP address or server (DNS, mail records, certificates, headers, ownership, robots.txt).
++research: read the specific web pages it links to, or OpenVibe's own content, and report.
++web: needs searching the live web: anything recent or changing (news, prices, releases, schedules, people's current roles).
++code: write, change, debug or review code.`;
++
++/**
++ * classify(task, { chat }) → { class, cost_usd, by }. `chat` is a provider client (server/agents/providers.js) or
++ * null; without one, or when it fails, the heuristic decides.
++ */
++async function classify(task, { chat = null, signal } = {}) {
++ const guess = heuristic(task);
++ if (!chat) return { class: guess, cost_usd: 0, by: 'heuristic' };
++ try {
++ const r = await chat({
++ messages: [{ role: 'system', content: SYSTEM }, { role: 'user', content: String(task).slice(0, 4000) }],
++ json: true, maxTokens: 30, signal,
++ });
++ const parsed = JSON.parse(String(r.text || '').trim().replace(/^```(?:json)?|```$/g, ''));
++ const cls = CLASSES.includes(parsed.class) ? parsed.class : guess;
++ return { class: cls, cost_usd: r.cost_usd || 0, by: 'model' };
++ } catch {
++ return { class: guess, cost_usd: 0, by: 'heuristic' };
++ }
++}
++
++const requirementFor = (cls) => `task:${CLASSES.includes(cls) ? cls : 'answer'}`;
++
++module.exports = { classify, heuristic, requirementFor, CLASSES };
ddiff --git a/server/agents/providers.js b/server/agents/providers.js
nnew file mode 100644
iindex 0000000..e843cde
---- /dev/null
++++ b/server/agents/providers.js
@@@ -0,0 +1,101 @@
++'use strict';
++
++/**
++ * The model providers' HTTP APIs, metered per call. Two shapes:
++ *
++ * chatClient(cfg) OpenAI-compatible /chat/completions with tools (DeepSeek, an open model on llama.cpp, OpenAI
++ * for checks): chat({ messages, tools, json, maxTokens, signal }) → { message, text, toolCalls,
++ * usage, cost_usd }
++ * responsesAgent(cfg) OpenAI's Responses API with its hosted web_search tool: run({ instructions, input, effort,
++ * signal }) → { text, sources, searches, usage, cost_usd }
++ *
++ * The cost of a call is its usage at the rate card the caller passes (catalog.js): cached input at its own rate when
++ * the provider reports it, and each web search at per_call_usd. A provider error becomes ProviderError with the
++ * provider's status and a short message; the key never appears in it.
++ */
++
++class ProviderError extends Error {
++ constructor(message, { status = 0, code = 'actor.provider.error' } = {}) { super(message); this.status = status; this.code = code; }
++}
++
++const clip = (s, n) => (String(s || '').length > n ? `${String(s).slice(0, n - 1)}…` : String(s || ''));
++
++async function post(fetchImpl, url, apiKey, body, { signal, timeoutMs }) {
++ const timeout = AbortSignal.timeout(timeoutMs);
++ const sig = signal ? AbortSignal.any([signal, timeout]) : timeout;
++ let res;
++ try {
++ res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}) }, body: JSON.stringify(body), signal: sig });
++ } catch (err) {
++ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });
++ throw new ProviderError(err && err.name === 'TimeoutError' ? 'the model did not answer in time' : 'the model provider could not be reached', { code: 'actor.provider.unreachable' });
++ }
++ const text = await res.text();
++ let json = null;
++ try { json = JSON.parse(text); } catch { /* not JSON */ }
++ if (!res.ok) {
++ const msg = (json && json.error && (json.error.message || json.error)) || clip(text, 200) || `HTTP ${res.status}`;
++ throw new ProviderError(`HTTP ${res.status}: ${clip(msg, 300)}`, { status: res.status, code: res.status === 429 ? 'actor.provider.rate_limited' : 'actor.provider.error' });
++ }
++ if (!json) throw new ProviderError('the model provider answered with something that is not JSON');
++ return json;
++}
++
++/** USD for a usage at a rate card: { input, cached, output } tokens, calls. */
++function priceUsage(rates, { input = 0, cached = 0, output = 0, calls = 0 }) {
++ const r = rates || {};
++ const cachedRate = r.cached_usd_per_mtok != null ? r.cached_usd_per_mtok : (r.input_usd_per_mtok || 0);
++ return (Math.max(0, input - cached) * (r.input_usd_per_mtok || 0) + cached * cachedRate + output * (r.output_usd_per_mtok || 0)) / 1e6 + calls * (r.per_call_usd || 0);
++}
++
++function chatClient({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.fetch, timeoutMs = 90_000 }) {
++ const reasoning = /^(gpt-5|o\d)/i.test(model || '');
++ return async function chat({ messages, tools = null, json = false, maxTokens = 1500, signal } = {}) {
++ const body = { model, messages };
++ if (reasoning) { body.max_completion_tokens = maxTokens + 1024; body.reasoning_effort = 'minimal'; } else { body.max_tokens = maxTokens; body.temperature = 0.2; }
++ if (tools && tools.length) { body.tools = tools; body.tool_choice = 'auto'; }
++ if (json) body.response_format = { type: 'json_object' };
++ const j = await post(fetchImpl, `${baseUrl}/chat/completions`, apiKey, body, { signal, timeoutMs });
++ const choice = (j.choices && j.choices[0]) || {};
++ const message = choice.message || { role: 'assistant', content: '' };
++ const u = j.usage || {};
++ const cached = u.prompt_cache_hit_tokens != null ? u.prompt_cache_hit_tokens : ((u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0);
++ const usage = { input: u.prompt_tokens || 0, cached, output: u.completion_tokens || 0 };
++ return {
++ message,
++ text: typeof message.content === 'string' ? message.content : '',
++ toolCalls: Array.isArray(message.tool_calls) ? message.tool_calls : [],
++ finish: choice.finish_reason || null,
++ model: j.model || model,
++ usage,
++ cost_usd: priceUsage(rates, usage),
++ };
++ };
++}
++
++/** Every URL the answer cites: url_citation annotations first, then bare links in the text. */
++function sourcesOf(output, text) {
++ const urls = [];
++ for (const item of output || []) {
++ if (item.type !== 'message') continue;
++ for (const c of item.content || []) for (const a of c.annotations || []) if (a.type === 'url_citation' && a.url) urls.push(a.url);
++ }
++ for (const m of String(text || '').matchAll(/https?:\/\/[^\s)\]>"']+/g)) urls.push(m[0].replace(/[.,;:]+$/, ''));
++ return [...new Set(urls.map((u) => u.replace(/\?utm_source=openai$/, '')))].slice(0, 20);
++}
++
++function responsesAgent({ baseUrl, apiKey, model, rates, fetchImpl = globalThis.fetch, timeoutMs = 150_000 }) {
++ return async function run({ instructions, input, effort = 'low', maxOutputTokens = 4000, signal } = {}) {
++ const body = { model, instructions, input, tools: [{ type: 'web_search' }], reasoning: { effort }, max_output_tokens: maxOutputTokens };
++ const j = await post(fetchImpl, `${baseUrl}/responses`, apiKey, body, { signal, timeoutMs });
++ const output = Array.isArray(j.output) ? j.output : [];
++ const text = output.filter((o) => o.type === 'message').flatMap((o) => (o.content || []).map((c) => c.text || '')).join('\n').trim();
++ const searches = output.filter((o) => o.type === 'web_search_call').map((o) => ({ kind: (o.action && o.action.type) || 'search', query: (o.action && (o.action.query || o.action.url)) || '' }));
++ const u = j.usage || {};
++ const usage = { input: u.input_tokens || 0, cached: (u.input_tokens_details && u.input_tokens_details.cached_tokens) || 0, output: u.output_tokens || 0, calls: searches.filter((s) => s.kind === 'search').length };
++ if (j.status && j.status !== 'completed' && !text) throw new ProviderError(`the agent stopped (${j.status}${j.incomplete_details ? `: ${j.incomplete_details.reason}` : ''})`);
++ return { text, sources: sourcesOf(output, text), searches, model: j.model || model, usage, cost_usd: priceUsage(rates, usage) };
++ };
++}
++
++module.exports = { chatClient, responsesAgent, priceUsage, sourcesOf, ProviderError };
ddiff --git a/server/agents/router.js b/server/agents/router.js
nnew file mode 100644
iindex 0000000..9ef201a
---- /dev/null
++++ b/server/agents/router.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/**
++ * Which agent system takes a task: openvibe-sdk/placement over the catalog's offers (ADR-044 §§3–4). Hard requirements
++ * first — the task class's capability, the trust private mode demands, the budget as a cost ceiling, the agent's
++ * health — then the mode's objective:
++ *
++ * cheapest → cheapest · balanced → balanced · fastest → lowest-latency · private → private (first-party only)
++ * best → balanced among agents that offer quality:high; when none can take the task, balanced among the rest,
++ * and the explanation says so (ADR-044 leaves what `best` maps to open; this is the first answer)
++ *
++ * The answer is a platform.placement-result@1, stored on the task as its explanation. An agent that is down carries
++ * the catalog's reason instead of the planner's "health down"; agents already tried on this task are listed with why.
++ */
++const contracts = require('openvibe-contracts');
++const { plan } = require('openvibe-sdk/placement');
++const catalog = require('./catalog');
++const { requirementFor } = require('./classify');
++
++const OBJECTIVE = { cheapest: 'cheapest', balanced: 'balanced', fastest: 'lowest-latency', private: 'private', best: 'balanced' };
++const MODES = Object.keys(OBJECTIVE);
++
++function planOnce({ cls, mode, pinned, budgetUsd, config, tried, now, measured, quality }) {
++ const req = {
++ kind: `actor.${cls}`,
++ mobility: 'job',
++ latency_class: 'interactive',
++ objective: OBJECTIVE[mode],
++ capabilities: [requirementFor(cls), ...(quality ? ['quality:high'] : [])],
++ };
++ if (mode === 'private') req.trust = ['first-party'];
++ if (budgetUsd != null) req.max_cost_usd = budgetUsd;
++ const agents = catalog.AGENTS.filter((a) => !tried.has(a.id) && (!pinned || a.id === pinned));
++ const offers = agents.map((a) => catalog.toOffer(a, config, { now, measured }));
++ const r = plan(req, offers, { now });
++ for (const c of r.candidates) {
++ if (!c.eligible && /^health /.test(c.excluded_because || '')) {
++ const av = catalog.availability(catalog.byId(c.id), config);
++ c.excluded_because = av.reason || c.excluded_because;
++ }
++ if (!c.eligible) for (const k of ['estimated_cost_usd', 'estimated_latency_ms']) if (c[k] == null || !Number.isFinite(c[k])) delete c[k];
++ }
++ return r;
++}
++
++/**
++ * route({ cls, mode, agent, budgetUsd, config, tried: Map<id, why>, now, measured }) → platform.placement-result@1
++ * (selected is null when nothing can take the task; the reasons say why).
++ */
++function route({ cls, mode = 'balanced', agent = null, budgetUsd = null, config, tried = new Map(), now = Date.now(), measured = {} }) {
++ const triedIds = new Set(tried.keys());
++ let r = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: mode === 'best' });
++ if (mode === 'best' && !r.selected) {
++ const fallback = planOnce({ cls, mode, pinned: agent, budgetUsd, config, tried: triedIds, now, measured, quality: false });
++ if (fallback.selected) r = { ...fallback, reasons: ['best mode: no high-quality agent can take this task, so the best of the rest', ...(fallback.reasons || [])] };
++ }
++ for (const [id, why] of tried) r.candidates.push({ id, eligible: false, excluded_because: `tried first: ${why}`.slice(0, 200) });
++ if (agent && !catalog.byId(agent)) r.reasons = [`no agent "${agent}" in the catalog`];
++ r.reasons = (r.reasons || []).map((x) => String(x).slice(0, 300));
++ const result = JSON.parse(JSON.stringify(r));
++ if (result.selected) {
++ const v = contracts.validate('platform.placement-result@1', result);
++ if (!v.valid) throw Object.assign(new Error(`invalid placement result: ${JSON.stringify(v.errors).slice(0, 300)}`), { code: 'actor.placement_invalid' });
++ }
++ return result;
++}
++
++/** Why nothing was selected, for the task's error: the budget, private mode, or no agent with the capability. */
++function whyNone(result, { cls, mode, budgetUsd }) {
++ const cands = result.candidates || [];
++ if (cands.some((c) => /over \$/.test(c.excluded_because || ''))) {
++ const cheapest = Math.min(...catalog.AGENTS.filter((a) => a.can.includes(requirementFor(cls))).map((a) => catalog.typicalCost(a.id)));
++ return { code: 'actor.budget.exceeded', detail: `The cheapest agent that can take this task would cost about $${cheapest.toFixed(3)}, over this task's $${Number(budgetUsd).toFixed(3)} budget. Raise the budget or ask something simpler.` };
++ }
++ if (mode === 'private') return { code: 'actor.no_agent', detail: `Private mode keeps the task on OpenVibe's own hardware, and no agent there can take a "${cls}" task yet (the open model answers simple questions only).` };
++ if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, and hosted coding runs are not open yet. Run openvibe-codes on your own machine: https://openvibe.codes/start' };
++ const down = cands.filter((c) => !c.eligible).map((c) => `${c.id}: ${c.excluded_because}`).join('; ');
++ return { code: 'actor.no_agent', detail: `No agent can take this task right now (${down || 'none configured'}).`.slice(0, 2000) };
++}
++
++module.exports = { route, whyNone, OBJECTIVE, MODES };
ddiff --git a/server/agents/tools.js b/server/agents/tools.js
nnew file mode 100644
iindex 0000000..c99002c
---- /dev/null
++++ b/server/agents/tools.js
@@@ -0,0 +1,124 @@
++'use strict';
++
++/**
++ * What the OpenVibe runtime can do, as model tools — every one of them an OpenVibe service's public API, called the
++ * way any app would call it (never a private path, never a URL the model invents for Actor to fetch itself):
++ *
++ * search_openvibe(query) OpenVibe.Search: streams, clips, pastes, posts, wiki pages, threads across the network
++ * find_tools(need) OpenVibe.Tools' catalog: which of its tools can do this, with each tool's input
++ * run_tool(tool, input) OpenVibe.Tools' run API (POST /api/v1/tools/<id>/run): DNS, mail records, certificates,
++ * headers, WHOIS, conversions, hashes, … — only tools the catalog marks callable by anyone
++ * read_page(url) OpenVibe.Tools' page reader, when Tools offers it: a web page as text
++ *
++ * Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to
++ * what an anonymous caller could already do, and every result is clipped before it goes back to the model.
++ */
++
++const CATALOG_TTL_MS = 10 * 60_000;
++const RESULT_CHARS = 6000;
++
++const clip = (s, n = RESULT_CHARS) => (s.length > n ? `${s.slice(0, n)}\n…(clipped)` : s);
++const json = (v) => clip(JSON.stringify(v));
++
++function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const { searchUrl, toolsUrl } = config.services;
++ let catalog = null;
++ let catalogAt = 0;
++ const schemas = new Map();
++
++ async function getJson(url, { signal, timeoutMs = 15_000, method = 'GET', body } = {}) {
++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
++ const res = await fetchImpl(url, { method, headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), 'user-agent': 'OpenVibeActor/1.0 (+https://openvibe.actor)' }, body: body ? JSON.stringify(body) : undefined, signal: sig });
++ const text = await res.text();
++ let data = null;
++ try { data = JSON.parse(text); } catch { /* not JSON */ }
++ return { status: res.status, ok: res.ok, data, text };
++ }
++
++ /** Tools' catalog: only tools with an API that run at once and that anyone may call. */
++ async function tools({ signal } = {}) {
++ if (catalog && now() - catalogAt < CATALOG_TTL_MS) return catalog;
++ try {
++ const r = await getJson(`${toolsUrl}/api/v1/tools`, { signal });
++ const list = (r.data && Array.isArray(r.data.tools) ? r.data.tools : [])
++ .filter((t) => t && t.api && t.execution === 'sync' && t.auth && t.auth.anonymous && /^[a-z0-9]+$/.test(t.id));
++ if (list.length) { catalog = list; catalogAt = now(); }
++ } catch (err) { log.warn('[Actor] Tools catalog unavailable:', err && err.message); }
++ return catalog || [];
++ }
++
++ async function inputSchema(id, { signal } = {}) {
++ if (schemas.has(id)) return schemas.get(id);
++ try {
++ const r = await getJson(`${toolsUrl}/api/v1/tools/${id}/schema`, { signal });
++ const s = r.data && r.data.$defs && r.data.$defs.input;
++ if (s) schemas.set(id, s);
++ return s || null;
++ } catch { return null; }
++ }
++
++ const words = (s) => String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
++
++ async function findTools(need, { signal } = {}) {
++ const list = await tools({ signal });
++ const q = new Set(words(need));
++ const scored = list.map((t) => {
++ const hay = words([t.id, t.name, t.summary, ...(t.keywords || [])].join(' '));
++ let score = 0;
++ for (const w of hay) if (q.has(w)) score += 1;
++ if (q.has(t.id)) score += 5;
++ return { t, score };
++ }).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 6);
++ const out = [];
++ for (const { t } of scored) {
++ out.push({ tool: t.id, name: t.name, does: t.summary, input: await inputSchema(t.id, { signal }), example: (t.examples && t.examples[0] && t.examples[0].input) || null });
++ }
++ return out;
++ }
++
++ async function runTool(id, input, { signal } = {}) {
++ const list = await tools({ signal });
++ if (!list.some((t) => t.id === id)) return { error: `no callable tool "${id}"; use find_tools to see which exist` };
++ const r = await getJson(`${toolsUrl}/api/v1/tools/${id}/run`, { method: 'POST', body: { input: input && typeof input === 'object' ? input : {} }, signal, timeoutMs: 30_000 });
++ if (!r.ok) return { error: (r.data && (r.data.detail || r.data.title || r.data.error)) || `Tools answered ${r.status}` };
++ return r.data && r.data.result !== undefined ? r.data.result : r.data;
++ }
++
++ async function searchOpenVibe(query, { signal } = {}) {
++ const r = await getJson(`${searchUrl}/api/v1/search?q=${encodeURIComponent(String(query || '').slice(0, 200))}&limit=8`, { signal });
++ if (!r.ok || !r.data) return { error: `Search answered ${r.status}` };
++ return (r.data.results || []).slice(0, 8).map((x) => ({ title: x.title, summary: x.summary, url: x.canonical_url, type: x.type, from: x.owner, published_at: x.published_at || null }));
++ }
++
++ /** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
++ async function definitions({ signal } = {}) {
++ const list = await tools({ signal });
++ const defs = [
++ { type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
++ { type: 'function', function: { name: 'find_tools', description: 'Find OpenVibe.Tools tools for a need (for example "mx records", "ssl certificate", "whois", "http headers", "base64", "hash"). Returns each tool id with its input schema and an example.', parameters: { type: 'object', properties: { need: { type: 'string' } }, required: ['need'] } } },
++ { type: 'function', function: { name: 'run_tool', description: 'Run one OpenVibe.Tools tool by id with its input object (use find_tools first to see the input).', parameters: { type: 'object', properties: { tool: { type: 'string' }, input: { type: 'object' } }, required: ['tool', 'input'] } } },
++ ];
++ if (list.some((t) => t.id === 'read')) {
++ defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
++ }
++ return defs;
++ }
++
++ async function call(name, args, { signal } = {}) {
++ const a = args && typeof args === 'object' ? args : {};
++ try {
++ if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
++ if (name === 'find_tools') return json(await findTools(a.need, { signal }));
++ if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));
++ if (name === 'read_page') return json(await runTool('read', { url: String(a.url || ''), max_chars: 12000 }, { signal }));
++ return json({ error: `unknown tool ${name}` });
++ } catch (err) {
++ if (signal && signal.aborted) throw err;
++ return json({ error: `the call failed: ${(err && err.message) || err}`.slice(0, 300) });
++ }
++ }
++
++ return { definitions, call, tools, findTools, runTool, searchOpenVibe };
++}
++
++module.exports = { createOpenVibeTools };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..9693164
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,144 @@
++'use strict';
++
++/**
++ * OpenVibe.Actor — OpenVibe's own agent and the router for agent work (plan T17, ADR-044). Express app factory;
++ * server/index.js listens, tests build their own instance with a temp database and mock neighbours.
++ *
++ * /, /agents, /docs, /tasks, /tasks/:id, /updates the pages (http/pages.js)
++ * /api/v1/* the task API (http/api.js)
++ * /auth/* Network SSO with PKCE (auth/sso.js)
++ * /api/health, /api/ready, /release.json, /metrics (loopback only)
++ */
++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 catalog = require('./agents/catalog');
++const { createAdapters } = require('./agents/adapters');
++const { createEngine } = require('./tasks/engine');
++const { createTaskStream } = require('./tasks/stream');
++const { createPrincipal } = require('./http/principal');
++const { createApi } = require('./http/api');
++const { createPageRoutes } = require('./http/pages');
++const { createActorReadiness } = require('./observability');
++const { createCallerLimits } = require('./http/caller-limits');
++const { assetVersion, send } = require('./render/layout');
++const { html } = require('./render/html');
++
++const PUBLIC_DIR = path.join(__dirname, '..', 'public');
++const VERSION = require('../package.json').version;
++
++/**
++ * opts: config, store, now (clock), fetchImpl (Network), providerFetch (model providers and OpenVibe services), log,
++ * limitsNow, callerLimits (false: count nobody, tests only), valkey, adapters (tests: stand-in agents)
++ */
++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 stream = createTaskStream({ log });
++ const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
++ const engine = createEngine({ config, s, adapters, stream, now: s.now, log });
++ const principal = createPrincipal({ config, keys });
++ const ctx = { config, s, keys, sso, stream, adapters, engine, principal, log };
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++ const release = require('openvibe-shared/release').createRelease({ service: 'actor', root: path.join(__dirname, '..') });
++ require('./render/layout').setRelease(release.release);
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'actor', release: release.release });
++ app.locals.metrics = metrics.registry;
++ 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;
++ ctx.limits = createCallerLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, enabled: opts.callerLimits !== false, valkey });
++
++ app.use(contracts.http.middleware());
++ app.use(helmet({
++ contentSecurityPolicy: {
++ directives: {
++ defaultSrc: ["'self'"],
++ // The OpenVibe Frame (theme-loader, navbar, footer) comes from the Network; the inline init is ours.
++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
++ frameSrc: ["'self'", 'https://openvibe.network'],
++ frameAncestors: ["'none'"],
++ objectSrc: ["'none'"],
++ baseUri: ["'self'"],
++ formAction: ["'self'", 'https://openvibe.network'],
++ },
++ },
++ frameguard: { action: 'deny' },
++ crossOriginEmbedderPolicy: false,
++ crossOriginResourcePolicy: { policy: 'same-site' },
++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
++ }));
++ app.use(cookieParser());
++
++ // ── Machine endpoints ───────────────────────────────────
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-actor', version: VERSION, tasks: engine.status(), streams: stream.status().streams }));
++ release.mount(app, { registry: metrics.registry });
++ const readiness = createActorReadiness({ s, config, engine, release: release.release, valkey });
++ app.get('/api/ready', readiness.handler);
++
++ // ── Who is asking (verified offline; refreshed when expired) ──
++ app.use(sso.middleware());
++
++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
++ app.use('/auth', sso.routes());
++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'actor', service: 'actor', host: 'openvibe.actor', name: 'OpenVibe.Actor', profile: 'ugc' })); }
++
++ // ── 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.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ app.use('/api/v1', createApi(ctx));
++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
++
++ // ── Pages ───────────────────────────────────────────────
++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createPageRoutes(ctx));
++ app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">giving Actor a task</a>, <a href="/agents">the agents</a> or <a href="/docs">the API</a>.</p>` }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ // Never log request bodies: tasks are people's own words.
++ log.error('[Actor]', 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 task was too long.');
++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
++ });
++
++ return { app, ctx };
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..522771a
---- /dev/null
++++ b/server/auth/keys.js
@@@ -0,0 +1,113 @@
++'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).
++ *
++ * The SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled, the refresh timer unref'd. This module
++ * is a thin shim so the rest of Actor (the readiness check, the SSO middleware) can keep talking to
++ * `keys.get()` / `keys.ensure()` / `keys.loaded()`,
++ * while verification goes through the SDK's verifyUserToken / verifyAppToken with `jwks: <url>`.
++ *
++ * Nothing the SDK says about a failed key fetch leaves this module: its message names the internal
++ * JWKS URL and the fetch error, so token.no_key answers 'signing key not loaded yet' (and an error
++ * without a code 'token does not verify'). Reasons about the token itself (audience, expiry,
++ * signature) are kept. Every rejection is logged.
++ */
++const sdk = require('openvibe-sdk/auth');
++
++/** Public reasons. The SDK's own text (the JWKS URL, the fetch error) never goes over the wire. */
++const KEY_UNAVAILABLE = 'signing key not loaded yet';
++const DOES_NOT_VERIFY = 'token does not verify';
++
++/**
++ * What a caller may be told. Only token.no_key carries the SDK's fetch text (the internal JWKS URL and the connect
++ * error); every other code's message is about the token itself ('not for openvibe.media', 'expired'), which a
++ * developer in the playground needs, so it is kept.
++ */
++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;
++}
++
++/** Resolve the JWKS URL for the configured Network (internal first, then public). */
++function jwksUrl(config) {
++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
++}
++
++/**
++ * `keys` is what app.js hands the rest of the service: a facade over the SDK's jwksClient. A synchronous caller
++ * wants a PEM, so we mirror the cached first key as PEM — refreshed every time the SDK gives us keys (ensure(),
++ * verifyUser(), verifyApp()).
++ */
++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const url = jwksUrl(config);
++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
++ let mirror = null; // { pem, count } — the first cached key as PEM
++
++ 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(`[Actor] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
++ }
++ /** First fetch (or wait out the backoff window). Refreshes the mirror. */
++ async function ensure() { await refreshMirror(); }
++ /** Cached public key as PEM — for openvibe-contracts' verifyServiceToken (synchronous). */
++ function get() { return mirror ? mirror.pem : null; }
++ function loaded() { return Boolean(mirror); }
++
++ /** The SDK's `now` is a NUMBER of milliseconds (it does Math.floor(now / 1000)); a function disables every expiry check. */
++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
++
++ // Verify a user (session) token via the SDK. Returns { ok, claims } | { ok: false, reason, code, expired }.
++ // `reason` is one of the two fixed public strings; the SDK's text is logged, never returned.
++ 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(`[Actor] user token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
++ }
++ }
++ // Verify an app token. Same shape and same fixed reasons. The playground reads v.reason for its detail.
++ 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(`[Actor] app token rejected (${code || 'error'}):`, (err && err.message) || err);
++ return { ok: false, reason: publicReason(err), code };
++ }
++ }
++
++ /**
++ * The PEM for the key a token's header names (a rotation publishes two keys: the first one is not always it), for
++ * openvibe-contracts' synchronous guard. The SDK refetches for an unknown kid (at most every 30 s). Null when no
++ * key is loaded; a token that names no kid gets the first key.
++ */
++ 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 };
\\ No newline at end of file
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..f0af54d
---- /dev/null
++++ b/server/auth/sso.js
@@@ -0,0 +1,258 @@
++'use strict';
++
++/**
++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `actor`.
++ *
++ * 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 (it cannot read the httpOnly cookie); { user: null } for a guest
++ *
++ * The person's Network access token lives in an httpOnly cookie (actor_at) no script can read: with it, a script
++ * could spend their free tier. The refresh token (actor_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 = 'actor_at';
++const REFRESH_COOKIE = 'actor_rt';
++const HINT_COOKIE = 'ov_sso_hint';
++const FLOW_COOKIE = 'actor_oauth';
++
++function pkcePair() {
++ const verifier = crypto.randomBytes(32).toString('base64url'); // 43 characters
++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
++ return { verifier, challenge };
++}
++
++/** Same-site relative paths only (never protocol-relative, never another origin). */
++function sanitizeNext(next) {
++ // Browsers drop tab and newline characters from a URL and read a backslash as "/": "/<TAB>/evil.com" would
++ // leave the site. A next with any control character or backslash goes home.
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ 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());
++ }
++
++ /** POST the Network token endpoint (internal URL). Throws { status, error } on refusal. */
++ async function tokenGrant(fields) {
++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
++ let res;
++ try {
++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body, signal: AbortSignal.timeout(10_000),
++ });
++ } catch (err) {
++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.access_token) {
++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
++ { status: res.status, error: (data && data.error) || 'invalid_grant' });
++ }
++ return data;
++ }
++
++ /**
++ * An Actor session is a Network SESSION token: audience openvibe.network and a person. The Network
++ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
++ * internal tokens with the same key and issuer; none of them 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,
++ // The person's Network access token: used for server-side Network calls, never rendered.
++ token,
++ };
++ }
++
++ /** One refresh per request, however many Network calls in it see a 401 (refresh tokens rotate). */
++ async function refresh(req, res) {
++ if (!req.actorRefresh) req.actorRefresh = await doRefresh(req, res);
++ return req.actorRefresh;
++ }
++
++ 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();
++ };
++ }
++
++ /**
++ * Run fn(token) as the viewer; when the Network answers 401 (expired or revoked session), refresh
++ * once and run it again. Other failures are returned to the caller unchanged.
++ */
++ 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), 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) : '/';
++ 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);
++ return res.redirect(next);
++ } catch (err) {
++ log.warn('[Actor] 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.');
++ }
++ });
++
++ // Sign-out ends the session, so another site must not be able to trigger it (a cross-site
++ // <img src=/auth/logout> or link). A same-origin navigation or form, or the address bar
++ // (Sec-Fetch-Site none), signs out directly; anything else gets a confirm button (POST).
++ 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'; // no fetch metadata and no Origin: an old browser navigating
++ };
++ 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))}` : '';
++ 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.Actor?</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.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));
++ });
++
++ r.get('/me', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ // No session at all (a guest: no access or refresh cookie) is signed out, not an error: the shared
++ // navbar asks this on every page view, and a 401 logged a console error on each (browser check,
++ // OpenVibe.Host). A session cookie that is present but invalid or expired still answers 401.
++ 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 } });
++ });
++ return r;
++ }
++
++ return { middleware, routes, asViewer, tokenGrant, pkcePair };
++}
++
++module.exports = { createSso, pkcePair, sanitizeNext, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..9c68dc1
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * OpenVibe.Actor configuration. Every value comes from the environment (production: /etc/openvibe/actor.env, see
++ * .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++require('dotenv').config();
++const trim = (s) => String(s || '').replace(/\/+$/, '');
++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
++const num = (v, def) => (v != null && v !== '' && Number.isFinite(Number(v)) ? Number(v) : def);
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4950);
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.actor' : `http://localhost:${port}`));
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++
++ return {
++ service: 'actor',
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
++ // Per-caller limits (server/http/caller-limits.js, roadmap WS-R task 4): the reads one caller (an app, a
++ // person, else an address) may make per minute and per hour. Creating a task has its own, tighter numbers.
++ limits: {
++ minute: Math.max(1, int(env.ACTOR_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.ACTOR_LIMITS_HOUR, 3000)),
++ },
++
++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (ACTOR_PGLITE_DIR
++ // overrides the directory).
++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.ACTOR_PGLITE_DIR || '' },
++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:actor:' },
++
++ // 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),
++ // The audience app, agent and service tokens for Actor's API carry (actor.task.* capabilities).
++ audience: env.ACTOR_AUDIENCE || 'openvibe.actor',
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'actor',
++ 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 },
++
++ // The model providers behind the agent systems (server/agents/catalog.js). A provider without its key is
++ // listed as unavailable, with the reason; nothing is invented. Keys are the operator's.
++ providers: {
++ deepseek: { apiKey: env.ACTOR_DEEPSEEK_API_KEY || '', baseUrl: trim(env.ACTOR_DEEPSEEK_BASE_URL || 'https://api.deepseek.com'), model: env.ACTOR_DEEPSEEK_MODEL || 'deepseek-flash' },
++ openai: { apiKey: env.ACTOR_OPENAI_API_KEY || '', baseUrl: trim(env.ACTOR_OPENAI_BASE_URL || 'https://api.openai.com/v1'), model: env.ACTOR_OPENAI_MODEL || 'gpt-5-mini', checkModel: env.ACTOR_OPENAI_CHECK_MODEL || 'gpt-5-nano' },
++ local: { url: trim(env.ACTOR_LOCAL_LLM_URL || ''), model: env.ACTOR_LOCAL_LLM_MODEL || '' },
++ },
++ // The OpenVibe services the runtime acts through.
++ services: {
++ searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
++ toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
++ },
++
++ // What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
++ // operator's ceiling over everyone in one UTC day. A request above the person's tier is refused, never lowered.
++ allowance: {
++ tasksPerDay: Math.max(0, int(env.ACTOR_FREE_TASKS_PER_DAY, 20)),
++ perTaskUsd: num(env.ACTOR_FREE_PER_TASK_USD, 0.05),
++ perDayUsd: num(env.ACTOR_FREE_PER_DAY_USD, 0.25),
++ },
++ spendCapUsdPerDay: num(env.ACTOR_MAX_COST_USD_PER_DAY, 2),
++ // Tasks running at once in this process; more wait queued.
++ maxRunning: Math.max(1, int(env.ACTOR_MAX_RUNNING, 4)),
++ // A task that runs longer than this is stopped and fails (actor.task.timeout).
++ taskTimeoutMs: Math.max(10_000, int(env.ACTOR_TASK_TIMEOUT_MS, 180_000)),
++ };
++}
++
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..da96968
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,49 @@
++'use strict';
++
++/**
++ * OpenVibe.Actor's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot. Tasks, their
++ * event logs and daily spend (tasks/store.js); nothing written here is ever a secret, token or credential.
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++const { ids } = require('openvibe-contracts');
++
++const TABLES = ['tasks', 'task_events', 'spend_daily'];
++
++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 actor)');
++ const dir = config.db.pgliteDir || DEV_PGLITE;
++ log.warn(`[Actor] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
++ fs.mkdirSync(dir, { recursive: true });
++ const db = createDb({ pglite: dir, service: 'actor', 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: 'actor-migrate', max: 1, log });
++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++ return createDb({ url: config.db.url, service: 'actor', 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(() => fn()),
++ close: () => db.close(),
++ };
++}
++
++async function openStore(config, { now, log } = {}) {
++ return createStore(await openDb(config, { log }), { now });
++}
++
++module.exports = { openDb, openStore, createStore, MIGRATIONS, TABLES };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..837b9f6
---- /dev/null
++++ b/server/http/api.js
@@@ -0,0 +1,104 @@
++'use strict';
++
++/**
++ * /api/v1 — Actor's API (capabilities in openvibe-contracts manifests/capabilities/actor.*):
++ *
++ * GET /agents actor.agent.read (public) the agent systems, their rate cards and availability
++ * POST /route actor.agent.read (public) which agent a task would go to, and why (nothing runs)
++ * POST /tasks actor.task.create give Actor a task (actor.task-create-request@1 → platform.task@1)
++ * GET /tasks actor.task.list your tasks, newest first
++ * GET /tasks/:id actor.task.read one task
++ * GET /tasks/:id/events actor.task.read its live stream (actor.task-event@1, Last-Event-ID)
++ * POST /tasks/:id/cancel actor.task.create stop it
++ *
++ * A task is visible to its requester only (or to tokens of the project it belongs to); anyone else gets 404, the same
++ * as a task that does not exist. Creating one goes through tasks/create.js (money limits before any work).
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++const store = require('../tasks/store');
++const catalog = require('../agents/catalog');
++const { heuristic } = require('../agents/classify');
++const { route, MODES } = require('../agents/router');
++const { createTask } = require('../tasks/create');
++
++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createApi(ctx) {
++ const { config, s, engine, stream, principal, limits } = ctx;
++ const r = asyncRouter();
++ const problem = (req, res, status, code, detail, extra) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
++
++ r.use(express.json({ limit: '64kb' }));
++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
++ r.use(principal.middleware);
++
++ // ── The catalog and the dry run (public) ─────────────────
++ r.get('/agents', limits.reads('actor.agent.read'), (req, res) => res.json({ agents: catalog.list(config) }));
++
++ r.post('/route', limits.budget('actor.route'), (req, res) => {
++ const b = req.body || {};
++ if (typeof b.task !== 'string' || !b.task.trim() || b.task.length > 20000) return problem(req, res, 422, 'actor.task.invalid', 'task: the instruction in plain words (1 to 20000 characters)');
++ const mode = b.mode == null ? 'balanced' : b.mode;
++ if (!MODES.includes(mode)) return problem(req, res, 422, 'actor.mode.invalid', `mode: one of ${MODES.join(', ')}`);
++ if (b.agent != null && !catalog.byId(b.agent)) return problem(req, res, 422, 'actor.agent.unknown', `no agent "${String(b.agent).slice(0, 64)}"; GET /api/v1/agents lists them`);
++ // The dry run classifies by the free heuristic: it never spends anything.
++ const cls = heuristic(b.task);
++ res.json({ class: cls, ...route({ cls, mode, agent: b.agent || null, budgetUsd: config.allowance.perTaskUsd, config }) });
++ });
++
++ // ── Tasks ────────────────────────────────────────────────
++ const owns = (p, row) => row && (row.requester === p.requester || (p.project && row.project_id === p.project));
++ async function load(req, res) {
++ if (!TASK_ID.test(req.params.id)) { problem(req, res, 404, 'actor.task.not_found', 'No such task.'); return null; }
++ const row = await store.getTask(s, req.params.id);
++ if (!owns(req.principal, row)) { problem(req, res, 404, 'actor.task.not_found', 'No such task.'); return null; }
++ return row;
++ }
++ const wire = (row) => store.toWire(row, { baseUrl: config.baseUrl });
++
++ r.post('/tasks', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {
++ const out = await createTask({ s, config, engine, principal: req.principal, body: req.body || {} });
++ if (out.code) {
++ if (out.retryAfter) res.set('Retry-After', String(out.retryAfter));
++ return problem(req, res, out.status, out.code, out.detail);
++ }
++ if (out.status === 201) res.set('Location', `/api/v1/tasks/${out.task.id}`);
++ return res.status(out.status).json(wire(out.task));
++ });
++
++ r.get('/tasks', principal.requireCapability('actor.task.list'), limits.reads('actor.task.list'), async (req, res) => {
++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
++ const before = typeof req.query.before === 'string' && TASK_ID.test(req.query.before) ? req.query.before : null;
++ const page = await store.listTasks(s, req.principal.requester, { limit, before });
++ res.json({ tasks: page.rows.map(wire), next: page.next });
++ });
++
++ r.get('/tasks/:id', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {
++ const row = await load(req, res);
++ if (row) res.json(wire(row));
++ });
++
++ r.get('/tasks/:id/events', principal.requireCapability('actor.task.read'), limits.budget('actor.task.stream'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return;
++ const after = Math.max(0, parseInt(req.get('last-event-id') || req.query.after, 10) || 0);
++ await stream.serve(req, res, {
++ taskId: row.id, after,
++ readBacklog: () => store.eventsAfter(s, row.id, after),
++ isEnded: async () => { const cur = await store.getTask(s, row.id); return !cur || !store.OPEN.includes(cur.state); },
++ });
++ });
++
++ r.post('/tasks/:id/cancel', principal.requireCapability('actor.task.create'), async (req, res) => {
++ const row = await load(req, res);
++ if (!row) return;
++ const after = await engine.cancel(row.id, req.principal.requester);
++ res.status(store.OPEN.includes(after.state) ? 202 : 200).json(wire(after));
++ });
++
++ return r;
++}
++
++module.exports = { createApi };
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
nnew file mode 100644
iindex 0000000..c9be3c2
---- /dev/null
++++ b/server/http/caller-limits.js
@@@ -0,0 +1,63 @@
++'use strict';
++
++/**
++ * Per-caller rate limits at Actor's API (roadmap WS-R task 4; openvibe-sdk/limits). The per-address limits in app.js
++ * stay; these count requests by who makes them:
++ *
++ * a person user:usr_… (a Bearer token or this site's session)
++ * an app, agent, service app:app_… / agent:agt_… / service:<slug>
++ * anyone else ip:<address>
++ *
++ * Past a limit the route answers 429 problem+json `rate_limited` with Retry-After before it does any work; the refusal
++ * is logged once and counted in actor_rate_limited_total{limit,window}. Reads take ACTOR_LIMITS_MINUTE /
++ * ACTOR_LIMITS_HOUR (120 and 3000); creating a task and the public dry run have their own numbers below (the daily
++ * free allowance is a separate, money limit: tasks/api). Counters live in this process unless VALKEY_URL is set.
++ *
++ * Never limited here: /api/health, /api/ready, /release.json, /metrics, sign-in and the pages.
++ */
++const { createActorLimiter, createValkeyLimitStore } = require('openvibe-sdk/limits');
++
++function caller(req) {
++ const p = req.principal;
++ if (p && p.requester) return p.requester;
++ const v = req.viewer;
++ if (v && v.kind === 'user' && v.subject) return `user:${v.subject}`;
++ return `ip:${req.ip || (req.socket && req.socket.remoteAddress) || 'unknown'}`;
++}
++
++/** The writes and the expensive reads, each with its numbers per caller (a minute, an hour). */
++const BUDGETS = {
++ 'actor.task.create': { minute: 6, hour: 60 },
++ 'actor.route': { minute: 30, hour: 600 },
++ 'actor.task.stream': { minute: 30, hour: 600 },
++};
++
++function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
++ const refused = registry
++ ? registry.counter({ name: 'actor_rate_limited_total', help: 'Requests refused 429 by a per-caller limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limiter = createActorLimiter({
++ limits: { minute: config.limits.minute, hour: config.limits.hour },
++ actor: enabled ? caller : () => null,
++ now,
++ ...(valkey ? { store: createValkeyLimitStore(valkey) } : {}),
++ onLimited(e) {
++ // The caller is a subject, a principal or an address, never a token.
++ log.warn(`[Limits] ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
++ };
++ const budgets = new Map(Object.entries(BUDGETS).map(([name, own]) => [name, limiter(name, own)]));
++ limiter.budget = (name) => {
++ const m = budgets.get(name);
++ if (!m) throw new Error(`limits: no budget named ${name}`);
++ return m;
++ };
++ return limiter;
++}
++
++module.exports = { createCallerLimits, caller, BUDGETS };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..00522a8
---- /dev/null
++++ b/server/http/discovery.js
@@@ -0,0 +1,111 @@
++'use strict';
++
++/**
++ * Crawl artifacts for openvibe.actor, built with openvibe-shared/seo: robots.txt, sitemap.xml, llms.txt and
++ * llms-full.txt, and the home page's JSON-LD. The public pages are for search engines and AI crawlers; tasks, sign-in
++ * and the API are not.
++ */
++const fs = require('fs');
++const path = require('path');
++const seo = require('openvibe-shared/seo');
++const cache = require('openvibe-shared/cache-policy');
++const { asyncRouter } = require('./router');
++
++const SITE_NAME = 'OpenVibe.Actor';
++const DESCRIPTION = 'OpenVibe.Actor is an AI agent router and OpenVibe\'s own agent: give it a task in plain words and it sends it to the best agent for it — OpenVibe\'s runtime acting through OpenVibe services, another platform\'s agent such as OpenAI\'s web agent, or an open model on OpenVibe hardware for private tasks — checks the answer with a second model and shows the cost and why. Like OpenRouter, for agents.';
++const DISALLOW = ['/auth/', '/api/', '/tasks'];
++
++const PAGE_TEXT = {
++ '/': ['OpenVibe.Actor home', 'Give a task in plain words; Actor picks the agent, checks the answer and shows the cost and why. What it can do today and what comes next.'],
++ '/agents': ['The agents', 'Every agent system Actor routes to, with what it can do, its trust class, its price per million tokens and whether it works now; the modes and the check.'],
++ '/docs': ['The Actor API', 'Create a task, watch it over server-sent events, read the result with its cost and explanation, cancel; list the agents and dry-run the router.'],
++ '/updates': ['What shipped on OpenVibe.Actor', 'This site\'s update log, from the network changelog feed.'],
++};
++
++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: 'BusinessApplication', keywords: 'ai agent, ai agent router, openrouter for agents, autonomous ai agent, ai assistant that does tasks, computer use agent, browser agent, cheap ai agent, open source ai agent, self-hosted ai agent, agent api' }),
++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
++ ];
++}
++
++const publicPages = () => [
++ { path: '/', changefreq: 'weekly', priority: 1.0 },
++ { path: '/agents', changefreq: 'weekly', priority: 0.9 },
++ { path: '/docs', changefreq: 'weekly', priority: 0.8 },
++ { path: '/updates', changefreq: 'daily', priority: 0.5 },
++];
++
++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 });
++
++ r.get('/robots.txt', (_req, res) => {
++ res.type('text/plain').set('Cache-Control', TEXT).send(
++ '# openvibe.actor: the public pages are for search and AI crawlers; tasks, sign-in and the API 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.Actor: an AI agent router and OpenVibe\'s own agent. A task in plain words goes to the best agent system for it, by what the task needs and the caller\'s mode (cheapest, balanced, best, fastest, private); a second model family checks the answer; the task records its cost to a fraction of a cent and why that agent was picked.',
++ details: 'Agents today: the OpenVibe runtime (a DeepSeek model acting through OpenVibe.Search and OpenVibe.Tools: DNS, mail records, certificates, headers, WHOIS and more), OpenAI\'s web agent (live web search with sources), and an open model on OpenVibe\'s own server for private mode. Coding goes to OpenVibe.Codes. Every page is server-rendered and readable without JavaScript. The API: POST /api/v1/tasks, GET /api/v1/tasks/:id, GET /api/v1/tasks/:id/events (server-sent events), POST /api/v1/tasks/:id/cancel, GET /api/v1/agents and POST /api/v1/route (public dry run). Tasks are platform.task@1 in openvibe-contracts.',
++ sections: [
++ { title: 'Start here', links: [
++ { title: 'Give Actor a task', url: abs('/'), note: 'what Actor is, how a task finds its agent, what works today' },
++ { title: 'The agents', url: abs('/agents'), note: 'every agent system with its price, trust class and availability; the modes; the check' },
++ { title: 'The API', url: abs('/docs'), note: 'tasks, the live stream, errors, limits' },
++ { title: 'What shipped on OpenVibe.Actor', url: abs('/updates') },
++ ] },
++ { title: 'Machine-readable', links: [
++ { title: 'The agents (JSON)', url: abs('/api/v1/agents'), note: 'actor.agent-list-result@1' },
++ { title: 'Dry-run the router', url: abs('/api/v1/route'), note: 'POST { task, mode? } → which agent and why (platform.placement-result@1)' },
++ { title: 'The free tier (JSON)', url: abs('/limits.json') },
++ { 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.Codes', url: 'https://openvibe.codes', note: 'the coding-agent harness Actor sends coding work to' },
++ { title: 'OpenVibe.Tools', url: 'https://openvibe.tools', note: 'the tools the OpenVibe runtime uses' },
++ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and actor.task.* grants' },
++ ] },
++ ],
++ }));
++ });
++
++ r.get('/llms-full.txt', (_req, res) => {
++ const pages = publicPages().map((p) => ({ url: p.path, title: PAGE_TEXT[p.path][0], text: PAGE_TEXT[p.path][1] }));
++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
++ site: SITE_NAME,
++ summary: 'Every public page of OpenVibe.Actor, one line each.',
++ base: site,
++ maxBytes: 64 * 1024,
++ sections: [{ title: 'Pages', pages }],
++ }));
++ });
++
++ r.get('/sitemap.xml', (_req, res) => {
++ const lastmod = siteUpdated();
++ const urls = publicPages().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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..06f1d36
---- /dev/null
++++ b/server/http/pages.js
@@@ -0,0 +1,365 @@
++'use strict';
++
++/**
++ * Public pages: the home page (give Actor a task), your tasks and one task (live progress, the answer, its cost and why
++ * that agent), the agents (Actor as "OpenRouter for agents": every agent system with its rate card), the API reference
++ * and the update log. Crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt, JSON-LD) are http/discovery.js.
++ *
++ * Every page works without JavaScript: the task form is a plain POST, a running task's page refreshes itself, and
++ * public/js/task.js only adds the live stream on top. What is shown is read, never restated: the agents and their
++ * prices are server/agents/catalog.js, the free tier is the running config.
++ */
++const ovServe = require('openvibe-shared/serve');
++const frame = require('openvibe-shared/frame');
++const showcase = require('openvibe-shared/showcase');
++const cache = require('openvibe-shared/cache-policy');
++const { asyncRouter } = require('./router');
++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
++const { sameOrigin } = require('./principal');
++const { html, raw, table, notice, time, badge } = require('../render/html');
++const { send } = require('../render/layout');
++const { markdown } = require('../render/markdown');
++const store = require('../tasks/store');
++const { createTask } = require('../tasks/create');
++const catalog = require('../agents/catalog');
++const { MODES } = require('../agents/router');
++
++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++const MODE_TEXT = {
++ cheapest: 'The lowest cost that can do the task.',
++ balanced: 'Cost and speed weighed together. The default.',
++ best: 'The highest-quality agents first, then the best of the rest.',
++ fastest: 'The quickest typical finish.',
++ private: 'Only OpenVibe\'s own hardware: nothing leaves OpenVibe.',
++};
++const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
++const STATE_KIND = { queued: '', running: 'info', verifying: 'info', succeeded: 'ok', failed: 'bad', cancelled: '' };
++const EXAMPLES = [
++ 'Who handles the mail for openvibe.network, and are its SPF and DMARC records set up properly?',
++ 'What changed in the latest Node.js LTS release? Give me the three changes that matter most, with sources.',
++ 'Explain the difference between a mutex and a semaphore with a short example.',
++ 'Is the TLS certificate of example.com valid, and when does it expire?',
++];
++
++const usd = (n) => (n == null ? '—' : n === 0 ? '$0' : n < 0.01 ? `$${Number(n).toFixed(4)}` : `$${Number(n).toFixed(2)}`);
++const perM = (n) => (n === 0 ? 'free' : `$${Number(n).toFixed(2)}`);
++const priceText = (rc) => (rc.input_usd_per_mtok === 0 && rc.output_usd_per_mtok === 0 ? 'Free (OpenVibe hardware)' : `${perM(rc.input_usd_per_mtok)} in · ${perM(rc.output_usd_per_mtok)} out per 1M tokens${rc.per_call_usd ? ` · ${usd(rc.per_call_usd)} a search` : ''}`);
++
++function createPageRoutes(ctx) {
++ const { config, s, engine } = ctx;
++ const r = asyncRouter();
++ const PUBLIC_CACHE = cache.htmlHeaders({ maxAge: 300 });
++ 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 requesterOf = (req) => `user:${req.viewer.subject}`;
++ const tier = config.allowance;
++
++ // ── The task box ─────────────────────────────────────────
++ function taskForm(req, { text = '', mode = 'balanced', problem = null } = {}) {
++ if (!signedIn(req)) {
++ return html`<div class="task-box task-box-guest">
++<p class="task-box-lead">Sign in with your OpenVibe account and ask anything like:</p>
++<ul class="examples">${EXAMPLES.map((e) => html`<li>“${e}”</li>`)}</ul>
++<div class="task-actions"><a class="sc-btn sc-primary" href="/auth/login?next=%2F%23task">Sign in to start</a><span class="muted small">Free: ${tier.tasksPerDay} tasks a day, no card.</span></div></div>`;
++ }
++ return html`<form class="task-box" method="post" action="/tasks">
++${problem ? notice(problem, 'warn') : ''}
++<label class="task-label" for="task">What should Actor do?</label>
++<textarea id="task" name="task" rows="4" maxlength="20000" required placeholder="${EXAMPLES[0]}">${text}</textarea>
++<fieldset class="modes"><legend>Mode</legend>
++${MODES.map((m) => html`<label class="mode"><input type="radio" name="mode" value="${m}"${m === mode ? raw(' checked') : ''}><span><b>${MODE_LABEL[m]}</b><small>${MODE_TEXT[m]}</small></span></label>`)}
++</fieldset>
++<div class="task-actions"><button class="sc-btn sc-primary" type="submit">Run it</button><span class="muted small">Up to ${usd(tier.perTaskUsd)} a task on the free tier; you see the real cost to a fraction of a cent.</span></div>
++</form>`;
++ }
++
++ // ── Home ─────────────────────────────────────────────────
++ function home(req, res, { status = 200, form = {} } = {}) {
++ const agents = catalog.list(config);
++ const hero = showcase.hero({
++ eyebrow: 'OpenVibe.Actor · your agent for everything · alpha',
++ title: 'Ask once.', accent: 'The right agent does it.',
++ lede: 'Give Actor a task in plain words. It picks the agent that fits: OpenVibe\'s own, acting through every OpenVibe service, or another platform\'s, like OpenAI\'s web agent. It runs the task, has a second model check the answer, and shows you what it cost and why that agent. Like OpenRouter, for agents.',
++ actions: signedIn(req) ? [{ label: 'Give it a task', href: '#task', primary: true }, { label: 'Your tasks', href: '/tasks' }] : [{ label: 'Sign in to start', href: '/auth/login?next=%2F', primary: true }, { label: 'See the agents', href: '/agents' }],
++ note: `Open source (AGPL-3.0). Free tier: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} each. The list below says exactly what works today.`,
++ aside: { html: String(routerCard(agents)) },
++ });
++ page(req, res, {
++ index: true, cache: signedIn(req) ? null : PUBLIC_CACHE,
++ jsonLd: homeJsonLd(config),
++ styles: [showcase.STYLESHEET],
++ body: html`${raw(hero)}
++<section class="sc-sec" id="task" aria-labelledby="h-task"><h2 id="h-task">Give Actor a task</h2>
++${taskForm(req, form)}</section>
++${raw(showcase.features({
++ title: 'What it can do today',
++ lede: 'Each of these is a real agent working through a real API, not a demo.',
++ items: [
++ { icon: 'ov:search', title: 'Search the live web, with sources', text: 'News, prices, releases, anything recent: OpenAI\'s agent searches and cites its sources.' },
++ { icon: 'ov:dns', title: 'Look up any domain or site', text: 'DNS, mail records, SPF and DMARC, certificates, headers, WHOIS: the runtime runs OpenVibe.Tools for real answers.' },
++ { icon: 'ov:network', title: 'Search the OpenVibe network', text: 'Streams, clips, pastes, posts and wiki pages across every OpenVibe site.' },
++ { icon: 'ov:text', title: 'Write and explain', text: 'Drafts, summaries, explanations, plans: the cheapest agent that does it well.' },
++ { icon: 'ov:host', title: 'Private mode', text: 'An open model on OpenVibe\'s own server answers, and nothing leaves OpenVibe.' },
++ { icon: 'ov:check', title: 'Checked before you see it', text: 'A model of another family reads every answer first; a failed check goes to the next agent.' },
++ ],
++ }))}
++${raw(showcase.steps({
++ title: 'How a task finds its agent',
++ items: [
++ { title: 'Sort the task', text: 'An answer, a lookup, research, a live web search or code: that decides what an agent must be able to do.' },
++ { title: 'Hard limits first', text: 'An agent that cannot do it, that private mode does not trust, that is down or that would cost more than your budget is out, and the task says why.' },
++ { title: 'Then your mode', text: 'Cheapest, Balanced, Best, Fastest or Private picks among the rest, through the same placement engine every OpenVibe service uses.' },
++ { title: 'Run, check, deliver', text: 'The agent works while you watch its steps; another model checks the answer; you get the answer, its sources, the cost and the explanation.' },
++ { title: 'Or hand it on', text: 'An agent that fails, or an answer that fails its check, goes to the next agent that can do it, within your budget.' },
++ ],
++ }))}
++<section class="sc-sec" aria-labelledby="h-agents"><h2 id="h-agents">The agents</h2>
++<p class="sc-lede">Every agent Actor can route to, with the price it charges and whether it is working right now. <a href="/agents">Prices, modes and details</a>.</p>
++<div class="agent-cards">${agents.map((a) => agentCard(a))}</div></section>
++${raw(showcase.compare({
++ title: 'Why a router, not one agent',
++ lede: 'One company\'s agent is as good as that company\'s models and tools. Actor picks per task, and can use theirs too.',
++ columns: ['OpenVibe.Actor', 'A single-vendor agent'],
++ rows: [
++ { label: 'Picks the best agent for each task', values: [true, false] },
++ { label: 'Can hand a task to another platform\'s agent', values: [true, false] },
++ { label: 'Shows the cost and why, per task', values: [true, false] },
++ { label: 'A second model family checks the answer', values: [true, false] },
++ { label: 'A private mode on its own hardware', values: [true, false] },
++ { label: 'Open source, self-hostable', values: [true, false] },
++ { label: 'Free tier', values: [true, null] },
++ ],
++ }))}
++<section class="sc-sec" aria-labelledby="h-status"><h2 id="h-status">What works today</h2>
++${table(['Piece', 'State'], [
++ ['The task router: modes, budgets, the explanation, live progress, cancel', 'Live.'],
++ ['OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools', 'Live.'],
++ ['OpenAI\'s web agent as one of the routed agents', 'Live.'],
++ ['Private mode on an open model on OpenVibe\'s server', 'Live: short answers, no tools yet.'],
++ ['A second-family check on every answer, and hand-off on failure', 'Live.'],
++ [html`The API (<a href="/docs">docs</a>): tasks, the live stream, the agents and a dry-run router`, 'Live, for people and for apps with actor.task.* grants from OpenVibe.Services.'],
++ ['Coding tasks through OpenVibe.Codes', html`Next: hosted coding runs need OpenVibe.Run sandboxes. Today, <a href="https://openvibe.codes/start">run openvibe-codes</a> on your machine.`],
++ ['Browser and desktop agents on OpenVibe\'s own cheap servers, that you can watch and take over', 'Next, on OpenVibe.Run workers.'],
++ ['More agent platforms, bring-your-own keys, paid tiers through OpenVibe.Billing', 'Planned.'],
++ ['Personal agents: memory you control, schedules, approvals, reachable from chat', 'Planned.'],
++ ])}</section>
++${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
++ }, status);
++ }
++
++ function routerCard(agents) {
++ const shown = agents.filter((a) => a.id !== 'codes');
++ return html`<figure class="router-card" aria-label="How Actor picked an agent for a sample task">
++<figcaption><span class="muted small">Sample task · Balanced</span><b>“Who handles the mail for openvibe.network, and is DMARC set up?”</b></figcaption>
++<ol>${shown.map((a, i) => html`<li class="${i === 0 ? 'pick' : ''}"><span class="rc-name">${a.name}</span><span class="rc-cost">~${usd(catalog.typicalCost(a.id))}</span><span class="rc-why">${i === 0 ? 'can run the DNS tools; cheapest that can' : a.can.includes('task:lookup') ? 'can do it, costs more' : 'cannot run lookups'}</span></li>`)}</ol>
++<p class="rc-foot">Checked by a second model before you see it.</p></figure>`;
++ }
++
++ function agentCard(a) {
++ return html`<article class="agent-card${a.available ? '' : ' off'}" id="card-${a.id}">
++<header><b>${a.name}</b>${a.available ? badge('working', 'ok') : badge('not here yet')}</header>
++<p>${(catalog.byId(a.id) || {}).blurb || ''}</p>
++<p class="muted small">${priceText(a.rate_card)}${a.model ? html` · <code>${a.model}</code>` : ''}</p>
++${a.available ? '' : html`<p class="muted small">${a.reason}</p>`}
++</article>`;
++ }
++
++ r.get('/', (req, res) => home(req, res));
++
++ // ── Create from the form (POST /tasks) ───────────────────
++ r.post('/tasks', require('express').urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2F');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A task must be sent from openvibe.actor itself.');
++ const text = String((req.body && req.body.task) || '').slice(0, 20000);
++ const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';
++ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode } });
++ if (out.code) return home(req, res, { status: out.status === 503 || out.status === 429 ? out.status : 422, form: { text, mode, problem: out.detail } });
++ return res.redirect(303, `/tasks/${out.task.id}`);
++ });
++
++ // ── Your tasks ───────────────────────────────────────────
++ r.get('/tasks', async (req, res) => {
++ if (!signedIn(req)) {
++ return page(req, res, { title: 'Your tasks', crumbs: [{ label: 'Your tasks' }], body: html`<h1>Your tasks</h1><p>Sign in to see the tasks you gave Actor.</p><p><a class="sc-btn sc-primary" href="/auth/login?next=%2Ftasks">Sign in</a></p>` });
++ }
++ const before = typeof req.query.before === 'string' && TASK_ID.test(req.query.before) ? req.query.before : null;
++ const pageRows = await store.listTasks(s, requesterOf(req), { limit: 30, before });
++ const today = await store.spendToday(s, requesterOf(req));
++ page(req, res, {
++ title: 'Your tasks', crumbs: [{ label: 'Your tasks' }],
++ body: html`<h1>Your tasks</h1>
++<p class="muted">Today: ${today.tasks} of ${tier.tasksPerDay} free tasks, ${usd(today.usd)} of ${usd(tier.perDayUsd)}. Tasks are kept ${store.RETENTION_DAYS} days.</p>
++<p><a class="sc-btn sc-primary" href="/#task">New task</a></p>
++${table(['Task', 'State', 'Agent', 'Cost', 'Started'], pageRows.rows.map((t) => {
++ const result = t.result ? (typeof t.result === 'string' ? JSON.parse(t.result) : t.result) : null;
++ return [html`<a href="/tasks/${t.id}">${t.task.length > 90 ? `${t.task.slice(0, 89)}…` : t.task}</a>`, badge(t.state, STATE_KIND[t.state]), result ? (catalog.byId(result.agent) || {}).name || result.agent : '—', usd(t.cost_usd == null ? null : Number(t.cost_usd)), time(t.created_at)];
++ }), { empty: 'No tasks yet.' })}
++${pageRows.next ? html`<p><a href="/tasks?before=${pageRows.next}">Older tasks</a></p>` : ''}`,
++ });
++ });
++
++ // ── One task ─────────────────────────────────────────────
++ r.get('/tasks/:id', async (req, res) => {
++ const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
++ if (!row || row.requester !== requesterOf(req)) {
++ return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);
++ }
++ const t = store.toWire(row, { baseUrl: config.baseUrl });
++ const events = await store.eventsAfter(s, row.id, 0);
++ const open = store.OPEN.includes(row.state);
++ page(req, res, {
++ title: 'Task', crumbs: [{ label: 'Your tasks', href: '/tasks' }, { label: row.id }],
++ scripts: open ? ['js/task.js'] : [],
++ noReferrer: true,
++ body: html`${open ? raw('<noscript><meta http-equiv="refresh" content="4"></noscript>') : ''}
++<article class="task" data-task="${row.id}" data-last="${row.last_seq}" data-open="${open ? '1' : ''}">
++<h1 class="task-text">${row.task}</h1>
++<p class="task-meta">${badge(t.state, STATE_KIND[t.state])} · ${MODE_LABEL[t.mode]} · budget ${usd(t.budget.per_task_usd)} · cost <span id="task-cost">${usd(t.cost ? t.cost.usd : null)}</span> · ${time(t.created_at)}</p>
++${open ? html`<form method="post" action="/tasks/${row.id}/cancel" class="inline"><button class="sc-btn" type="submit">Cancel</button></form>` : ''}
++${t.result ? resultBlock(t.result) : ''}
++${t.error ? notice(html`<strong>${t.state === 'failed' ? 'It did not finish' : 'Stopped'}.</strong> ${t.error.detail}`, 'warn') : ''}
++${t.state === 'cancelled' && !t.error ? notice('Cancelled. What was spent before the cancel stays spent.') : ''}
++<section class="progress" aria-labelledby="h-steps"><h2 id="h-steps">Steps</h2>
++<ol class="steps-log" id="steps-log">${events.filter((e) => e.kind === 'output').map(stepItem)}</ol>
++${open ? html`<p class="muted small" id="live-note">Working… this page updates as the agent works.</p>` : ''}</section>
++${t.explanation ? explanationBlock(t.explanation) : ''}
++<p class="muted small">Need it in code? <code>GET /api/v1/tasks/${row.id}</code> (<a href="/docs">API</a>).</p>
++</article>`,
++ });
++ });
++
++ function resultBlock(result) {
++ const agent = catalog.byId(result.agent);
++ return html`<section class="answer" aria-labelledby="h-answer"><h2 id="h-answer">Answer</h2>
++<div class="prose">${raw(markdown(result.answer, { headingOffset: 2 }))}</div>
++${result.sources && result.sources.length ? html`<h3>Sources</h3><ul class="sources">${result.sources.map((u) => html`<li><a href="${u}" rel="nofollow ugc noopener" target="_blank">${u}</a></li>`)}</ul>` : ''}
++<p class="muted small">By ${agent ? agent.name : result.agent}${result.model ? html` (<code>${result.model}</code>)` : ''} · checked by ${result.checked && result.checked.by}${result.checked && !result.checked.cross_family ? ' (same family: private mode)' : ''}${result.checked && result.checked.reason ? `: ${result.checked.reason}` : ''}</p>
++</section>`;
++ }
++
++ function stepItem(e) {
++ const label = { plan: 'Plan', text: 'Says', tool_call: 'Uses', tool_result: 'Got', check: 'Check', handoff: 'Hands on' }[e.step] || e.step;
++ const body = e.step === 'tool_call' ? html`<code>${e.tool}</code> ${e.input || ''}` : e.step === 'tool_result' ? html`<code>${e.tool}</code> <span class="muted">${(e.text || '').slice(0, 240)}</span>` : (e.text || '');
++ return html`<li class="step step-${e.step}${e.is_error ? ' err' : ''}"><span class="step-k">${label}</span><span class="step-v">${body}</span></li>`;
++ }
++
++ function explanationBlock(explanation) {
++ return html`<details class="why"><summary>Why this agent</summary>
++${explanation.map((p, i) => html`<h3>${i ? `Attempt ${i + 1}` : 'First choice'}: ${p.selected ? (catalog.byId(p.selected) || {}).name || p.selected : 'no agent'} <span class="muted small">(${p.objective})</span></h3>
++${(p.reasons || []).length ? html`<p class="muted">${p.reasons.join('; ')}</p>` : ''}
++${table(['Agent', 'Could take it', 'Estimated cost', 'Why not'], (p.candidates || []).map((c) => [(catalog.byId(c.id) || {}).name || c.id, c.eligible ? 'yes' : 'no', c.estimated_cost_usd != null ? `~${usd(c.estimated_cost_usd)}` : '—', c.excluded_because || (c.id === p.selected ? 'picked' : '')]))}`)}
++</details>`;
++ }
++
++ r.post('/tasks/:id/cancel', async (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.redirect(303, '/tasks');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A cancel must come from openvibe.actor itself.');
++ const row = await store.getTask(s, req.params.id);
++ if (row && row.requester === requesterOf(req)) await engine.cancel(row.id, requesterOf(req));
++ return res.redirect(303, `/tasks/${req.params.id}`);
++ });
++
++ // ── The agents ───────────────────────────────────────────
++ r.get('/agents', (req, res) => {
++ const agents = catalog.list(config);
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'The agents',
++ description: 'Every agent system OpenVibe.Actor routes a task to — OpenVibe\'s own runtime, OpenAI\'s web agent, an open model on OpenVibe hardware, OpenVibe.Codes — with what each can do, its trust class and its price per million tokens.',
++ crumbs: [{ label: 'Agents' }],
++ body: html`<h1>The agents</h1>
++<p>Actor is a router for agents, the way OpenRouter is one for models: each task goes to the agent system that can do it, then by your mode. Prices are typed in from each provider's own price page by a person, with the day it was checked; Actor charges the real usage of each call at these prices. Machine-readable: <a href="/api/v1/agents"><code>GET /api/v1/agents</code></a>.</p>
++${table(['Agent', 'Kind', 'Can do', 'Trust', 'Price', 'Typical time', 'Now'], agents.map((a) => [
++ html`<strong id="${a.id}">${a.name}</strong><br><code>${a.id}</code>${a.model ? html`<br><span class="muted small">${a.model}</span>` : ''}`,
++ a.kind,
++ a.can.filter((c) => c.startsWith('task:')).map((c) => c.slice(5)).join(', '),
++ a.trust,
++ html`${priceText(a.rate_card)}<br><a class="muted small" href="${a.rate_card.source}" rel="noopener">source</a> <span class="muted small">checked ${a.rate_card.verified_at}</span>`,
++ `~${Math.round(a.typical_latency_ms / 1000)} s`,
++ a.available ? badge('working', 'ok') : html`${badge('not here')}<br><span class="muted small">${a.reason}</span>`,
++ ]))}
++<h2 id="modes">Modes</h2>
++${table(['Mode', 'What it picks'], MODES.map((m) => [html`<strong>${MODE_LABEL[m]}</strong> <code>${m}</code>`, MODE_TEXT[m]]))}
++<h2 id="classes">What a task needs</h2>
++<p>Actor sorts each task first, and only agents that can do that kind of task are considered:</p>
++<ul><li><strong>answer</strong>: writing, explaining, planning, anything a capable model knows.</li>
++<li><strong>lookup</strong>: facts about a domain, address or site (DNS, mail records, certificates, headers, WHOIS), from OpenVibe.Tools.</li>
++<li><strong>research</strong>: reading the pages a task names, or OpenVibe's own content.</li>
++<li><strong>web</strong>: a live web search, for anything recent.</li>
++<li><strong>code</strong>: writing or changing code, through OpenVibe.Codes.</li></ul>
++<h2 id="check">The check</h2>
++<p>Before you see an answer, a model of another family reads it: DeepSeek's answers are checked by OpenAI's smallest model and OpenAI's by DeepSeek. In private mode only OpenVibe's own model may read the task, so it checks its own answer and the task says so. A failed check sends the task to the next agent that can do it.</p>
++<h2 id="trust">Trust</h2>
++<p><strong>first-party</strong> runs on OpenVibe's own hardware; <strong>external</strong> means the task's text goes to that provider (the OpenVibe runtime is OpenVibe's code, but its model runs at DeepSeek). Private mode takes only first-party agents.</p>`,
++ });
++ });
++
++ // ── The API reference ────────────────────────────────────
++ r.get('/docs', (req, res) => {
++ const b = config.baseUrl;
++ const create = `curl -s ${b}/api/v1/tasks \\
++ -H "authorization: Bearer $TOKEN" \\
++ -H 'content-type: application/json' \\
++ -d '{"task":"Is the TLS certificate of example.com valid, and when does it expire?","mode":"cheapest"}'
++
++# → 201 { "id": "tsk_…", "state": "queued", "progress": { "stream_url": "…/events" }, … }`;
++ const watch = `curl -N ${b}/api/v1/tasks/tsk_…/events -H "authorization: Bearer $TOKEN"
++
++id: 3
++event: output
++data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","step":"tool_call","tool":"run_tool","input":"ssl {\\"target\\":\\"example.com\\"}"}`;
++ page(req, res, {
++ index: true, cache: PUBLIC_CACHE,
++ title: 'API',
++ description: 'The OpenVibe.Actor API: create a task, watch it live over server-sent events, read the result with its cost and explanation, cancel it; list the agents and dry-run the router.',
++ crumbs: [{ label: 'API' }],
++ body: html`<h1>The API</h1>
++<p>Everything the site does is this API. Tasks are <a href="https://openvibe.services/docs/contracts/platform.task@1"><code>platform.task@1</code></a>; the contracts and capabilities are in <a href="https://github.com/OpenVibers/OpenVibe.Contracts">OpenVibe.Contracts</a>.</p>
++<h2 id="auth">Who can call it</h2>
++<ul><li><strong>A person</strong>: their OpenVibe Network token as <code>Authorization: Bearer</code> (or this site's sign-in). Tasks are theirs and use their free tier.</li>
++<li><strong>An app or agent</strong>: a Network token for audience <code>openvibe.actor</code> with the <code>actor.task.*</code> capability the route needs, granted on <a href="https://openvibe.services">OpenVibe.Services</a>. Tasks belong to the app's project.</li>
++<li><strong>Anyone</strong>: <code>GET /api/v1/agents</code> and <code>POST /api/v1/route</code> need no token.</li></ul>
++${table(['Route', 'Capability', 'What it does'], [
++ [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key? }</code>. Answers 201 with the task (queued).`],
++ [html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, 'The task: state, result, cost, explanation, error.'],
++ [html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@1</code>): <code>state</code>, <code>output</code> (each step), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
++ [html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
++ [html`<code>GET /api/v1/tasks</code>`, html`<code>actor.task.list</code>`, html`Your tasks, newest first; <code>?limit=</code>, <code>?before=</code> (the <code>next</code> cursor).`],
++ [html`<code>GET /api/v1/agents</code>`, 'public', 'The agent systems, what each can do, its price and whether it works now.'],
++ [html`<code>POST /api/v1/route</code>`, 'public', html`<code>{ task, mode? }</code> → which agent would take it and why, for every candidate. Nothing runs and nothing is charged.`],
++ ])}
++${raw(showcase.code({ title: 'Create a task, then watch it', samples: [{ label: 'Create', lang: 'bash', code: create }, { label: 'Watch', lang: 'bash', code: watch }] }))}
++<h2 id="limits">Limits and money</h2>
++<p>The free tier: ${tier.tasksPerDay} tasks a day, up to ${usd(tier.perTaskUsd)} a task and ${usd(tier.perDayUsd)} a day (<a href="/limits.json">limits.json</a>). A budget above the tier is refused (<code>actor.budget.over_tier</code>), never lowered. A task whose cheapest capable agent would cost more than its budget fails before running (<code>actor.budget.exceeded</code>). <code>cost.usd</code> is what each model call and web search really cost at the <a href="/agents">published prices</a>.</p>
++<h2 id="errors">Errors</h2>
++<p>Every refusal is RFC 9457 <code>application/problem+json</code> with a stable <code>code</code>. A task that fails says why in <code>error</code>:</p>
++${table(['Code', 'Meaning'], [
++ [html`<code>actor.task.invalid</code>`, 'The request does not match actor.task-create-request@1.'],
++ [html`<code>actor.budget.over_tier</code>`, 'The budget is above what the free tier allows.'],
++ [html`<code>actor.allowance.exhausted</code> / <code>actor.budget.day_spent</code>`, 'Today\'s free tasks or budget are used; Retry-After says when it resets.'],
++ [html`<code>actor.capacity.spent</code>`, 'Actor\'s free capacity for everyone is used up for today.'],
++ [html`<code>actor.budget.exceeded</code>`, 'The task could not be done within its budget.'],
++ [html`<code>actor.no_agent</code>`, 'No agent that can do this kind of task is available (the detail says which and why).'],
++ [html`<code>actor.agents.exhausted</code>`, 'Every agent that could do it failed or gave an answer that failed its check.'],
++ [html`<code>actor.check.unavailable</code>`, 'The answer could not be checked, so it was not delivered.'],
++ [html`<code>actor.task.timeout</code> / <code>actor.task.interrupted</code>`, 'It ran too long, or Actor restarted while it ran.'],
++ ])}`,
++ });
++ });
++
++ // ── Limits and the update log ────────────────────────────
++ r.get('/limits.json', (req, res) => {
++ res.set('Cache-Control', cache.htmlHeaders({ maxAge: 300 }));
++ res.json({ service: 'actor', free_tier: { tasks_per_day: tier.tasksPerDay, per_task_usd: tier.perTaskUsd, per_day_usd: tier.perDayUsd }, task_timeout_seconds: Math.round(config.taskTimeoutMs / 1000), retention_days: store.RETENTION_DAYS });
++ });
++ r.get('/updates', (req, res) => page(req, res, { index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Actor', body: raw(frame.updatesBody({ service: 'actor', siteName: 'OpenVibe.Actor' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) }));
++
++ // ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
++ r.use(createDiscoveryRoutes(ctx));
++ return r;
++}
++
++module.exports = { createPageRoutes, MODE_TEXT };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..fc3a372
---- /dev/null
++++ b/server/http/principal.js
@@@ -0,0 +1,98 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — req.principal:
++ *
++ * { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
++ * or this site's session cookie
++ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
++ * a Network app, agent or service token for
++ * audience openvibe.actor; each route names
++ * ONE capability it needs (actor.task.*)
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor spend their allowance.
++ * A person acting for themself needs no capability; an app, agent or service needs the one the route names.
++ */
++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}$/;
++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];
++
++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 requesterOfSub(sub) {
++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
++ return sub; // app:app_…, agent:agt_…, mod:mod_…
++}
++
++function sameOrigin(req, baseUrl) {
++ const site = String(req.get('sec-fetch-site') || '');
++ if (site) return site === 'same-origin';
++ const origin = req.get('origin');
++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
++ return false;
++}
++
++function createPrincipal({ config, keys }) {
++ async function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const publicKey = await keys.pemForToken(token);
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
++ }
++ // A person's own Network token (the same kind this site's session holds).
++ 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', requester: `user:${v.claims.subject_id}`, project: null, viaSession: false } };
++ }
++ const viewer = req.viewer;
++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
++ return { principal: { kind: 'user', requester: `user:${viewer.subject}`, project: null, 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();
++ }
++
++ /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */
++ function require_(cap) {
++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of Actor's capabilities`);
++ return function guard(req, res, next) {
++ const p = req.principal;
++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.actor, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.actor).', ctx: req.ov });
++ if (p.kind === 'user') {
++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.actor itself.', ctx: req.ov });
++ return next();
++ }
++ const decision = capabilities.check(p.claims, cap);
++ if (decision.allowed) return next();
++ return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
++ };
++ }
++
++ return { middleware, resolve, requireCapability: require_ };
++}
++
++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..088ba89
---- /dev/null
++++ b/server/http/router.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++/**
++ * express.Router() whose get/post 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']) {
++ const orig = r[method].bind(r);
++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
++ try {
++ const out = h(req, res, next);
++ if (out && typeof out.catch === 'function') out.catch(next);
++ } catch (err) { next(err); }
++ })));
++ }
++ return r;
++}
++
++module.exports = { asyncRouter };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..a434a3b
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,50 @@
++'use strict';
++
++/**
++ * OpenVibe.Actor — process entry. `node server/index.js`
++ * Listens on PORT (4950) behind nginx (deploy/).
++ */
++const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
++const store = require('./tasks/store');
++
++const PRUNE_EVERY_MS = 6 * 60 * 60_000;
++
++/**
++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then running tasks are stopped and recorded as
++ * interrupted (the person can ask again; nothing re-runs behind their back), the JWKS refresher stops and the store
++ * closes. Exported so a test can inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, ctx, exit, signals, timers = [] }) {
++ return gracefulStop({
++ name: 'Actor', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.engine.stop(), () => ctx.keys.client.stop(), () => ctx.s.close()],
++ });
++}
++
++async function start() {
++ const { app, ctx } = await createApp();
++ const { config } = ctx;
++ // A task a stopped process left open did not finish: it is recorded so, never re-run.
++ const interrupted = await store.failInterrupted(ctx.s);
++ if (interrupted.length) console.warn(`[Actor] ${interrupted.length} task(s) interrupted by the last stop were marked failed`);
++
++ const server = app.listen(config.port, config.host, () => {
++ const available = require('./agents/catalog').list(config).filter((a) => a.available).map((a) => a.id);
++ console.log(`[Actor] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store}); agents available: ${available.join(', ') || 'none'}`);
++ });
++ server.keepAliveTimeout = 65_000;
++ ctx.keys.client.start();
++ // Tasks older than the retention window (tasks/store.js RETENTION_DAYS) go, with their events.
++ const prune = setInterval(() => { store.prune(ctx.s).catch((err) => console.warn('[Actor] prune failed:', err && err.message)); }, PRUNE_EVERY_MS);
++ prune.unref();
++
++ createLifecycle({ server, ctx, timers: [prune] });
++ return { server, ctx };
++}
++
++if (require.main === module) {
++ start().catch((err) => { console.error('[Actor] failed to start:', err); process.exit(1); });
++}
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..732f54b
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,51 @@
++'use strict';
++/**
++ * Truthful readiness for GET /api/ready (openvibe-shared/ready, Track O).
++ *
++ * db required a real round trip to Actor's PostgreSQL store (tasks, events, spend)
++ * agents required at least one agent system can take a task (a provider key or an open model is configured)
++ * network_jwks optional the Network signing key is loaded; without it nobody can sign in or call the task API
++ * oauth_client optional OV_OAUTH_CLIENT_SECRET is set (sign-in needs it)
++ * valkey optional shared per-caller limit counters
++ */
++const { jwksStatus } = require('openvibe-sdk/auth');
++const { createReadiness } = require('openvibe-shared/ready');
++const catalog = require('./agents/catalog');
++
++function createActorReadiness({ s, config, engine, valkey = null, release = null }) {
++ return createReadiness({
++ service: 'actor',
++ 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: 'agents', required: true,
++ check: () => {
++ const list = catalog.list(config);
++ const up = list.filter((a) => a.available).map((a) => a.id);
++ return up.length ? { ok: true, detail: { available: up, tasks: engine.status() } } : 'no agent system is configured (set a provider key or an open model)';
++ },
++ },
++ { 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 task API are unavailable';
++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
++ },
++ },
++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
++ ],
++ });
++}
++
++module.exports = { createActorReadiness };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..c0f69d9
---- /dev/null
++++ b/server/render/html.js
@@@ -0,0 +1,45 @@
++'use strict';
++
++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** Tagged template: interpolations are escaped unless wrapped in raw(). Arrays are joined. */
++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>` : '—');
++
++/** A <table> from headers and rows of cells (cells are already html or plain values). */
++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>`;
++}
++
++/** A problem as returned by Network, Events or Media — shown as it came, never softened. */
++function problemBox(p, { title = 'The request failed' } = {}) {
++ if (!p) return '';
++ return html`<div class="problem" role="alert"><strong>${title}</strong>
++<p>${p.detail || 'No detail was given.'}</p>
++<p class="muted small">HTTP ${p.status || '—'} · <code>${p.code || 'unknown'}</code>${p.requestId ? html` · request <code>${p.requestId}</code>` : ''}</p></div>`;
++}
++
++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
++const csrfField = (token) => html`<input type="hidden" name="csrf" value="${token}">`;
++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
++
++module.exports = { esc, html, raw, Raw, render, code, time, table, problemBox, notice, csrfField, badge };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..864cca1
---- /dev/null
++++ b/server/render/layout.js
@@@ -0,0 +1,126 @@
++'use strict';
++
++/**
++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript:
++ * - <head>: title, description, canonical, robots (pages are noindex unless they say otherwise), Open Graph/Twitter,
++ * JSON-LD (openvibe-shared/seo), plus Actor's extras: the shared app icon, stylesheets, the boost marker
++ * - the OpenVibe Frame: theme-loader, web-runtime, navbar and footer scripts (the shell boots the
++ * navbar; the footer is initialised below), a <noscript> navigation bar and the server-rendered footer
++ * - Actor's own script only where a page offers an in-browser convenience (a task's live progress, copy buttons)
++ */
++const crypto = require('crypto');
++const ovServe = require('openvibe-shared/serve');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const frame = require('openvibe-shared/frame');
++const shell = require('openvibe-shared/shell');
++const cache = require('openvibe-shared/cache-policy');
++const { html, raw, esc } = require('./html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Actor';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const NAV = [
++ { label: 'Your tasks', href: '/tasks' },
++ { label: 'Agents', href: '/agents' },
++ { label: 'API', href: '/docs' },
++];
++
++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)}`;
++
++// The deployed release (app.js sets it from openvibe-shared/release): openvibe-shared/boost swaps a page in place only
++// between pages of the same release, and does a normal load across a deploy.
++let RELEASE = 'dev';
++function setRelease(id) { if (id) RELEASE = String(id); }
++
++/**
++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ */
++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: 'actor',
++ 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}', // filled from the current page (boost moves between pages)
++ logoutUrl: '/auth/logout?next={path}', // Sign out in the shared navbar ends this site's session too
++ notificationsRealtime: true, // the bell hears new notifications over OpenVibe.Events (Shared 1.22.0)
++ };
++ // This site's own account links live in the shared navbar's account menu (the page's account
++ // bar below is only for visitors without JavaScript).
++ if (signedIn) nav.menu = { before: [{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }] };
++ const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
++ const account = signedIn
++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
++ const crumbs = (o.crumbs || []).length
++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
++ : '';
++ const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title: o.title || `${SITE_NAME}: your agent for everything`,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || 'Give a task in plain words: Actor sends it to the best agent for it, OpenVibe\'s own or another platform\'s, checks the result and shows the cost and why. Like OpenRouter, for agents.',
++ canonical,
++ robots: o.index ? 'index, follow' : 'noindex, nofollow',
++ jsonLd: (o.jsonLd || []).filter(Boolean),
++ home: '/',
++ navLinks: NAV,
++ navbar: nav,
++ footer,
++ head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
++${appIcon.headTags({ site: 'actor' })}
++<link rel="stylesheet" href="${asset('css/actor.css')}">
++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
++${render(scripts)}
++<meta name="ov-boost" content="actor@${esc(RELEASE)}">
++<script src="${ovServe.url('boost.js')}" data-main="#main" defer></script>`,
++ body: `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
++<main id="main" class="page">
++${render(crumbs)}
++${render(o.body)}
++${o.path === '/' ? frame.shipped({ service: 'actor', title: `Recently shipped on ${SITE_NAME}` }) : ''}
++</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>`,
++ });
++}
++
++function render(v) { return require('./html').render(v); }
++
++/** Send a page; a page rendered for a signed-in person is private and never cached. */
++function send(res, status, o) {
++ // A page rendered for a signed-in person names them in the account bar: never shared caches.
++ const personal = o.viewer && o.viewer.kind === 'user';
++ if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? 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, NETWORK_URL, NAV, raw, html };
ddiff --git a/server/render/markdown.js b/server/render/markdown.js
nnew file mode 100644
iindex 0000000..08333b4
---- /dev/null
++++ b/server/render/markdown.js
@@@ -0,0 +1,80 @@
++'use strict';
++
++/**
++ * A deliberately small Markdown renderer for agents' answers. Everything is escaped first; only headings, paragraphs,
++ * lists, fenced code, tables, bold, italics, inline code and links are recognised. Links are kept only when http(s) or
++ * relative, and a model's link is never vouched for: rel="nofollow ugc noopener", opened in a new tab.
++ */
++const { esc } = require('./html');
++
++function inline(s) {
++ let out = esc(s);
++ out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
++ out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
++ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>');
++ out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (m, text, href) => {
++ const h = href.replace(/&amp;/g, '&');
++ if (/^https?:\/\//.test(h)) return `<a href="${esc(h)}" rel="nofollow ugc noopener" target="_blank">${text}</a>`;
++ if (/^[#./a-zA-Z0-9]/.test(h) && !/^[a-z]+:/i.test(h)) return `<a href="${esc(h)}">${text}</a>`;
++ return text;
++ });
++ // Bare links the model wrote out, outside an <a> already made above.
++ out = out.replace(/(^|[\s(])(https?:\/\/[^\s<)"']+[^\s<)"'.,;:!?])/g, (m, pre, url) => `${pre}<a href="${url}" rel="nofollow ugc noopener" target="_blank">${url}</a>`);
++ return out;
++}
++
++const cells = (line) => line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
++const isSeparator = (line) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(line);
++
++function markdown(src, { headingOffset = 1 } = {}) {
++ const lines = String(src || '').replace(/\r\n/g, '\n').split('\n');
++ const out = [];
++ let para = [];
++ let list = null; // { type: 'ul' | 'ol', items: [] }
++ let fence = null;
++ const flushPara = () => { if (para.length) { out.push(`<p>${inline(para.join(' '))}</p>`); para = []; } };
++ const flushList = () => { if (list) { out.push(`<${list.type}>${list.items.map((i) => `<li>${inline(i)}</li>`).join('')}</${list.type}>`); list = null; } };
++ for (let i = 0; i < lines.length; i++) {
++ const line = lines[i];
++ if (fence) {
++ if (/^```/.test(line)) { out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`); fence = null; } else fence.push(line);
++ continue;
++ }
++ if (/^```/.test(line)) { flushPara(); flushList(); fence = []; continue; }
++ // A table: a header row, a separator row, then rows while lines start with |.
++ if (/^\s*\|/.test(line) && i + 1 < lines.length && isSeparator(lines[i + 1])) {
++ flushPara(); flushList();
++ const head = cells(line);
++ const rows = [];
++ i += 2;
++ while (i < lines.length && /^\s*\|/.test(lines[i])) { rows.push(cells(lines[i])); i++; }
++ i--;
++ out.push(`<div class="table-wrap"><table><thead><tr>${head.map((h) => `<th scope="col">${inline(h)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);
++ continue;
++ }
++ const h = line.match(/^(#{1,6})\s+(.*)$/);
++ if (h) {
++ flushPara(); flushList();
++ const level = Math.min(6, h[1].length + headingOffset);
++ out.push(`<h${level}>${inline(h[2])}</h${level}>`);
++ continue;
++ }
++ const li = line.match(/^\s*(?:[-*]|(\d+)\.)\s+(.*)$/);
++ if (li) {
++ flushPara();
++ const type = li[1] ? 'ol' : 'ul';
++ if (!list || list.type !== type) { flushList(); list = { type, items: [] }; }
++ list.items.push(li[2]);
++ continue;
++ }
++ if (!line.trim()) { flushPara(); flushList(); continue; }
++ if (list && /^\s{2,}\S/.test(line)) { list.items[list.items.length - 1] += ` ${line.trim()}`; continue; }
++ flushList();
++ para.push(line.trim());
++ }
++ if (fence) out.push(`<pre><code>${esc(fence.join('\n'))}</code></pre>`);
++ flushPara(); flushList();
++ return out.join('\n');
++}
++
++module.exports = { markdown, inline };
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
nnew file mode 100644
iindex 0000000..8441919
---- /dev/null
++++ b/server/tasks/create.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * Creating a task — the one path the API (POST /api/v1/tasks) and the site's form both take. Money limits come before
++ * any work: the person's tier (a budget above it is refused, never lowered), their tasks and spend today, and the
++ * operator's daily ceiling over everyone. Returns { status, task } (201 created, 200 an idempotent repeat) or
++ * { status, code, detail, retryAfter? } for a problem.
++ */
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const store = require('./store');
++const catalog = require('../agents/catalog');
++
++function untilMidnight(nowMs) {
++ const n = new Date(nowMs);
++ return Math.ceil((Date.UTC(n.getUTCFullYear(), n.getUTCMonth(), n.getUTCDate() + 1) - n.getTime()) / 1000);
++}
++
++async function createTask({ s, config, engine, principal, body }) {
++ const v = contracts.validate('actor.task-create-request@1', body);
++ if (!v.valid) return { status: 422, code: 'actor.task.invalid', detail: (v.errors || []).map((e) => `${e.path || e.instancePath || ''} ${e.message}`.trim()).join('; ').slice(0, 500) || 'invalid request' };
++ const tier = config.allowance;
++ const perTask = body.budget && body.budget.per_task_usd != null ? body.budget.per_task_usd : tier.perTaskUsd;
++ const perDay = body.budget && body.budget.per_day_usd != null ? body.budget.per_day_usd : tier.perDayUsd;
++ if (perTask > tier.perTaskUsd || perDay > tier.perDayUsd) {
++ return { status: 422, code: 'actor.budget.over_tier', detail: `The free tier allows up to $${tier.perTaskUsd} per task and $${tier.perDayUsd} a day; paid tiers come with OpenVibe.Billing.` };
++ }
++ if (body.agent && !catalog.byId(body.agent)) return { status: 422, code: 'actor.agent.unknown', detail: `no agent "${body.agent}"; GET /api/v1/agents lists them` };
++
++ const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null })).digest('hex');
++ if (body.idempotency_key) {
++ const prior = await store.byIdempotency(s, principal.requester, body.idempotency_key);
++ if (prior) {
++ if (prior.idem_hash !== hash) return { status: 409, code: 'actor.idempotency.conflict', detail: 'This idempotency_key was used for a different task.' };
++ return { status: 200, task: prior };
++ }
++ }
++ const retryAfter = untilMidnight(s.now());
++ const mine = await store.spendToday(s, principal.requester);
++ if (mine.tasks >= tier.tasksPerDay) return { status: 429, code: 'actor.allowance.exhausted', detail: `The free tier runs ${tier.tasksPerDay} tasks a day; it resets at midnight UTC.`, retryAfter };
++ if (mine.usd >= perDay) return { status: 429, code: 'actor.budget.day_spent', detail: `Today's tasks already cost $${mine.usd.toFixed(3)} of the $${perDay} daily budget; it resets at midnight UTC.`, retryAfter };
++ const all = await store.spendToday(s, '*');
++ if (all.usd >= config.spendCapUsdPerDay) return { status: 503, code: 'actor.capacity.spent', detail: 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.', retryAfter };
++
++ const row = await store.insertTask(s, {
++ id: s.newId('tsk'), requester: principal.requester, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
++ budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
++ });
++ engine.submit(row.id);
++ return { status: 201, task: row };
++}
++
++module.exports = { createTask, untilMidnight };
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
nnew file mode 100644
iindex 0000000..0fecc9f
---- /dev/null
++++ b/server/tasks/engine.js
@@@ -0,0 +1,175 @@
++'use strict';
++
++/**
++ * The task engine: a queued task runs here, in the process that accepted it (no claims, no leases).
++ *
++ * queued → running: classify the task (a cheap model, or the heuristic in private mode) and route it
++ * (agents/router.js); nothing eligible → failed with why (budget, private mode, no agent)
++ * running: the selected agent's adapter works, its steps stream as events, every call's cost is metered at once
++ * verifying: another model family checks the answer (agents/check.js)
++ * → succeeded with the result, the cost and the explanation; or the next eligible agent gets the task (an error, a
++ * failed check) — at most MAX_ATTEMPTS agents, each placement appended to the explanation — or failed
++ *
++ * Budgets are hard (ADR-044 §8): the planner never picks an agent whose typical cost is over what is left, and an
++ * adapter stops before a call when nothing is left. Cancel aborts the running call; what was spent stays spent.
++ * At most config.maxRunning tasks run at once; the rest wait queued in order.
++ */
++const store = require('./store');
++const catalog = require('../agents/catalog');
++const { classify, heuristic } = require('../agents/classify');
++const { route, whyNone } = require('../agents/router');
++const { check } = require('../agents/check');
++
++const MAX_ATTEMPTS = 2;
++
++function createEngine({ config, s, adapters, stream, now = () => Date.now(), log = console }) {
++ const running = new Map(); // id → AbortController
++ const waiting = [];
++ let stopped = false;
++
++ const iso = () => new Date(now()).toISOString();
++
++ async function emit(id, body) {
++ const event = await store.appendEvent(s, id, { at: iso(), ...body });
++ stream.publish(id, event);
++ return event;
++ }
++ async function setState(id, state, { agentForEvent, ...patch } = {}) {
++ const row = await store.updateTask(s, id, { state, ...patch });
++ const ended = ['succeeded', 'failed', 'cancelled'].includes(state);
++ await emit(id, { kind: ended ? 'end' : 'state', state, ...(agentForEvent ? { agent: agentForEvent } : {}) });
++ if (ended) stream.end(id);
++ return row;
++ }
++
++ function submit(id) {
++ if (stopped) return;
++ waiting.push(id);
++ pump();
++ }
++ function pump() {
++ while (!stopped && running.size < config.maxRunning && waiting.length) {
++ const id = waiting.shift();
++ const ac = new AbortController();
++ running.set(id, ac);
++ execute(id, ac.signal)
++ .catch((err) => log.error('[Actor] task crashed:', id, err && err.message))
++ .finally(() => { running.delete(id); pump(); });
++ }
++ }
++
++ async function execute(id, signal) {
++ const row = await store.getTask(s, id);
++ if (!row || row.state !== 'queued') return;
++ const budget = Number(row.budget_task);
++ let spent = 0;
++ const meter = async (usd) => {
++ const v = Number(usd) || 0;
++ if (!v) return;
++ spent += v;
++ // Billing (T5) is not in the loop yet: every cent is the free allowance's, and says so.
++ await store.updateTask(s, id, { cost_usd: spent, free_usd: spent });
++ await store.addSpend(s, row.requester, v);
++ };
++ const budgetLeft = () => budget - spent;
++ const timer = setTimeout(() => { if (running.has(id)) running.get(id).abort('timeout'); }, config.taskTimeoutMs);
++ timer.unref();
++ const explanation = [];
++ const tried = new Map();
++ try {
++ await setState(id, 'running', { cost_usd: 0, free_usd: 0 });
++ // What kind of task this is. Private mode never sends the text anywhere to find out.
++ let cls;
++ if (row.mode === 'private' || !adapters.checkers.deepseek) {
++ cls = heuristic(row.task);
++ } else {
++ const c = await classify(row.task, { chat: adapters.checkers.deepseek, signal });
++ await meter(c.cost_usd);
++ cls = c.class;
++ }
++ await emit(id, { kind: 'output', step: 'plan', text: `This is a "${cls}" task.` });
++
++ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
++ const placement = route({ cls, mode: row.mode, agent: row.agent, budgetUsd: Math.max(0, budgetLeft()), config, tried, now: now() });
++ explanation.push(placement);
++ await store.updateTask(s, id, { explanation });
++ if (!placement.selected) {
++ if (attempt > 1) break;
++ const why = whyNone(placement, { cls, mode: row.mode, budgetUsd: budgetLeft() });
++ return await setState(id, 'failed', { error_code: why.code, error_detail: why.detail, finished_at: iso() });
++ }
++ const agentId = placement.selected;
++ const agent = catalog.byId(agentId);
++ await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
++ let out;
++ try {
++ out = await adapters.run[agentId]({ task: row.task, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
++ } catch (err) {
++ if (signal.aborted) throw err;
++ const why = (err && err.message) || String(err);
++ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: `${agent.name} stopped: ${why}`.slice(0, 2000), is_error: true });
++ if (err && err.code === 'actor.budget.exceeded') {
++ return await setState(id, 'failed', { error_code: 'actor.budget.exceeded', error_detail: `The task used its whole $${budget.toFixed(3)} budget before an agent finished.`, finished_at: iso() });
++ }
++ tried.set(agentId, why);
++ continue;
++ }
++ await setState(id, 'verifying', { agentForEvent: agentId });
++ const verdict = await check({ task: row.task, answer: out.text, agentId, mode: row.mode, checkers: adapters.checkers, signal });
++ await meter(verdict.cost_usd);
++ await emit(id, { kind: 'output', step: 'check', agent: agentId, text: `${verdict.ok ? 'Checked' : 'Check failed'}${verdict.by ? ` by ${verdict.by}${verdict.cross ? '' : ' (same family: private mode)'}` : ''}: ${verdict.reason || ''}`.slice(0, 2000), is_error: !verdict.ok });
++ if (verdict.ok) {
++ const result = { answer: out.text, sources: out.sources || [], agent: agentId, model: out.model || catalog.modelOf(agent, config) || null, class: cls, checked: { by: verdict.by, cross_family: !!verdict.cross, reason: verdict.reason } };
++ return await setState(id, 'succeeded', { result, finished_at: iso() });
++ }
++ if (verdict.unavailable) {
++ return await setState(id, 'failed', { error_code: 'actor.check.unavailable', error_detail: `The answer could not be checked (${verdict.reason}), so it was not delivered.`, finished_at: iso() });
++ }
++ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: `Unchecked answer, not delivered:\n\n${out.text}`.slice(0, 8000) });
++ tried.set(agentId, `its answer failed the check: ${verdict.reason}`);
++ await setState(id, 'running');
++ }
++ const last = [...tried.entries()].pop();
++ return await setState(id, 'failed', { error_code: 'actor.agents.exhausted', error_detail: `No agent finished this task with an answer that passed its check${last ? ` (last: ${last[0]}, ${last[1]})` : ''}.`.slice(0, 2000), finished_at: iso() });
++ } catch (err) {
++ if (signal.aborted) {
++ if (signal.reason === 'shutdown') return undefined; // stop() records it as interrupted
++ if (signal.reason === 'cancelled') return await setState(id, 'cancelled', { finished_at: iso() });
++ return await setState(id, 'failed', { error_code: 'actor.task.timeout', error_detail: `The task ran longer than ${Math.round(config.taskTimeoutMs / 1000)} seconds and was stopped.`, finished_at: iso() });
++ }
++ log.error('[Actor] task failed unexpectedly:', id, err && err.message);
++ return await setState(id, 'failed', { error_code: 'actor.internal', error_detail: 'Something went wrong on Actor\'s side. Nothing more will be charged for this task.', finished_at: iso() });
++ } finally {
++ clearTimeout(timer);
++ }
++ }
++
++ /** Cancel: a queued task ends at once; a running one is aborted and ends cancelled when its call returns. */
++ async function cancel(id, by) {
++ const row = await store.getTask(s, id);
++ if (!row || !store.OPEN.includes(row.state)) return row;
++ await store.updateTask(s, id, { cancel_at: iso(), cancel_by: by || null });
++ const i = waiting.indexOf(id);
++ if (i >= 0) {
++ waiting.splice(i, 1);
++ return setState(id, 'cancelled', { finished_at: iso() });
++ }
++ const ac = running.get(id);
++ if (ac) ac.abort('cancelled');
++ return store.getTask(s, id);
++ }
++
++ /** Shutdown: running tasks are stopped and recorded as interrupted; queued ones too. */
++ async function stop() {
++ stopped = true;
++ for (const ac of running.values()) ac.abort('shutdown');
++ waiting.length = 0;
++ await store.failInterrupted(s).catch(() => {});
++ }
++
++ const status = () => ({ running: running.size, waiting: waiting.length });
++
++ return { submit, cancel, stop, status, MAX_ATTEMPTS };
++}
++
++module.exports = { createEngine, MAX_ATTEMPTS };
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
nnew file mode 100644
iindex 0000000..afe9367
---- /dev/null
++++ b/server/tasks/store.js
@@@ -0,0 +1,127 @@
++'use strict';
++
++/**
++ * Tasks, their event logs and daily spend in Actor's PostgreSQL database (migrations/0001_tasks.sql), and the task as
++ * the wire sees it (platform.task@1). Every function takes the store (server/db.js createStore) first.
++ *
++ * A requester is 'type:id' (identity.subject-ref@1 in one column): user:usr_…, app:app_…, agent:agt_…, service:<slug>.
++ * Spend is kept per requester and UTC day, plus requester '*' for everyone (the operator's daily ceiling).
++ */
++
++const OPEN = ['queued', 'running', 'verifying'];
++const RETENTION_DAYS = 30;
++
++const dayOf = (ms) => new Date(ms).toISOString().slice(0, 10);
++const num = (v) => (v == null ? null : Number(v));
++const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
++
++function subjectRef(requester) {
++ const i = String(requester).indexOf(':');
++ return { type: requester.slice(0, i), id: requester.slice(i + 1) };
++}
++
++async function insertTask(s, t) {
++ await s.db.query(
++ `INSERT INTO tasks (id, requester, project_id, task, mode, agent, budget_task, budget_day, state, idem_key, idem_hash, created_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'queued', $9, $10, $11)`,
++ [t.id, t.requester, t.project_id || null, t.task, t.mode, t.agent || null, t.budget_task, t.budget_day, t.idem_key || null, t.idem_hash || null, t.created_at]);
++ await addSpend(s, t.requester, 0, { tasks: 1 });
++ return getTask(s, t.id);
++}
++
++const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id]);
++const byIdempotency = (s, requester, key) => s.db.maybe('SELECT * FROM tasks WHERE requester = $1 AND idem_key = $2', [requester, key]);
++
++async function listTasks(s, requester, { limit = 20, before = null } = {}) {
++ const rows = before
++ ? await s.db.many('SELECT * FROM tasks WHERE requester = $1 AND id < $2 ORDER BY id DESC LIMIT $3', [requester, before, limit + 1])
++ : await s.db.many('SELECT * FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT $2', [requester, limit + 1]);
++ const page = rows.slice(0, limit);
++ return { rows: page, next: rows.length > limit ? page[page.length - 1].id : null };
++}
++
++/** Patch a task's columns (only the ones named). */
++async function updateTask(s, id, patch) {
++ const cols = Object.keys(patch);
++ if (!cols.length) return getTask(s, id);
++ const json = new Set(['result', 'explanation']);
++ const sets = cols.map((c, i) => `${c} = $${i + 2}${json.has(c) ? '::jsonb' : ''}`);
++ const vals = cols.map((c) => (json.has(c) && patch[c] != null ? JSON.stringify(patch[c]) : patch[c]));
++ await s.db.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $1`, [id, ...vals]);
++ return getTask(s, id);
++}
++
++/** Append one event; returns it with its seq (the task's own counter). */
++async function appendEvent(s, taskId, body) {
++ return s.tx(async () => {
++ const seq = await s.db.value('UPDATE tasks SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq', [taskId]);
++ const event = { task_id: taskId, seq: Number(seq), ...body };
++ await s.db.query('INSERT INTO task_events (task_id, seq, body) VALUES ($1, $2, $3::jsonb)', [taskId, event.seq, JSON.stringify(event)]);
++ return event;
++ });
++}
++
++async function eventsAfter(s, taskId, after = 0) {
++ const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 AND seq > $2 ORDER BY seq', [taskId, after]);
++ return rows.map((r) => parse(r.body));
++}
++
++/** Add to a requester's and everyone's spend today (tasks counts only the requester's). */
++async function addSpend(s, requester, usd, { tasks = 0 } = {}) {
++ const day = dayOf(s.now());
++ for (const [who, n] of [[requester, tasks], ['*', 0]]) {
++ await s.db.query(
++ `INSERT INTO spend_daily (requester, day, usd, tasks) VALUES ($1, $2, $3, $4)
++ ON CONFLICT (requester, day) DO UPDATE SET usd = spend_daily.usd + EXCLUDED.usd, tasks = spend_daily.tasks + EXCLUDED.tasks`,
++ [who, day, usd, n]);
++ }
++}
++
++async function spendToday(s, requester) {
++ const row = await s.db.maybe('SELECT usd, tasks FROM spend_daily WHERE requester = $1 AND day = $2', [requester, dayOf(s.now())]);
++ return { usd: row ? Number(row.usd) : 0, tasks: row ? Number(row.tasks) : 0 };
++}
++
++/** At boot: a task a stopped process left open did not finish, and is not re-run behind the person's back. */
++async function failInterrupted(s) {
++ const rows = await s.db.many(
++ `UPDATE tasks SET state = 'failed', error_code = 'actor.task.interrupted',
++ error_detail = 'Actor restarted while this task was running. Nothing was charged after the restart; ask again.',
++ finished_at = $1, result = NULL
++ WHERE state IN ('queued', 'running', 'verifying') RETURNING id`, [s.iso()]);
++ return rows.map((r) => r.id);
++}
++
++/** Tasks (and their events) older than the retention window go. */
++async function prune(s) {
++ const cutoff = new Date(s.now() - RETENTION_DAYS * 86_400_000).toISOString();
++ const n = await s.db.exec('DELETE FROM tasks WHERE created_at < $1', [cutoff]);
++ await s.db.query('DELETE FROM spend_daily WHERE day < $1', [cutoff.slice(0, 10)]);
++ return n;
++}
++
++/** A task row → platform.task@1. */
++function toWire(row, { baseUrl }) {
++ const ended = !OPEN.includes(row.state);
++ const t = {
++ id: row.id,
++ requester: subjectRef(row.requester),
++ task: row.task,
++ mode: row.mode,
++ budget: { per_task_usd: num(row.budget_task), per_day_usd: num(row.budget_day) },
++ state: row.state,
++ created_at: row.created_at,
++ finished_at: row.finished_at || null,
++ result: row.state === 'succeeded' ? parse(row.result) : null,
++ cost: row.cost_usd == null ? null : { usd: Math.round(num(row.cost_usd) * 1e6) / 1e6, free_allowance_used: Math.round(num(row.free_usd || 0) * 1e6) / 1e6 },
++ explanation: parse(row.explanation),
++ progress: ended ? null : { stream_url: `${baseUrl}/api/v1/tasks/${row.id}/events`, last_seq: Number(row.last_seq || 0), events: ['output', 'state', 'end'] },
++ cancel: row.cancel_at ? { requested_at: row.cancel_at, ...(row.cancel_by ? { requested_by: subjectRef(row.cancel_by) } : {}) } : null,
++ error: row.error_code ? { code: row.error_code, detail: row.error_detail || '' } : null,
++ };
++ if (row.project_id) t.project_id = row.project_id;
++ if (t.explanation && !t.explanation.length) t.explanation = null;
++ return t;
++}
++
++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, addSpend, spendToday, failInterrupted, prune, toWire, subjectRef, dayOf, OPEN, RETENTION_DAYS };
ddiff --git a/server/tasks/stream.js b/server/tasks/stream.js
nnew file mode 100644
iindex 0000000..873c750
---- /dev/null
++++ b/server/tasks/stream.js
@@@ -0,0 +1,61 @@
++'use strict';
++
++/**
++ * Live task streams: GET /api/v1/tasks/:id/events is text/event-stream of actor.task-event@1. A reader first gets
++ * every stored event after its Last-Event-ID (or ?after=), then each new one as the engine publishes it; the stream
++ * closes after the `end` event. Events are stored before they are published (tasks/store.js appendEvent), and a reader
++ * subscribes before reading its backlog, so nothing falls between the two; a seq it already has is never sent twice.
++ * The stream is best-effort; the task record is the truth.
++ */
++
++const HEARTBEAT_MS = 15_000;
++
++function createTaskStream({ log = console } = {}) {
++ const subs = new Map(); // task id → Set<{ res, last }>
++
++ function write(sub, event) {
++ if (event.seq <= sub.last) return;
++ sub.last = event.seq;
++ try { sub.res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`); } catch (err) { log.warn('[Actor] stream write failed:', err && err.message); }
++ }
++
++ function publish(taskId, event) {
++ for (const sub of subs.get(taskId) || []) write(sub, event);
++ }
++
++ function end(taskId) {
++ const set = subs.get(taskId);
++ subs.delete(taskId);
++ for (const sub of set || []) { try { sub.res.end(); } catch { /* gone */ } }
++ }
++
++ /**
++ * Serve one reader. readBacklog() → stored events after `after`; isEnded() → whether the task has ended (read after
++ * the backlog, so an end that happened meanwhile closes the stream).
++ */
++ async function serve(req, res, { taskId, after = 0, readBacklog, isEnded }) {
++ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
++ res.flushHeaders();
++ res.write(': stream open\n\n');
++ const sub = { res, last: after };
++ let set = subs.get(taskId);
++ if (!set) { set = new Set(); subs.set(taskId, set); }
++ set.add(sub);
++ const beat = setInterval(() => { try { res.write(': keep-alive\n\n'); } catch { /* gone */ } }, HEARTBEAT_MS);
++ beat.unref();
++ const leave = () => {
++ clearInterval(beat);
++ const s = subs.get(taskId);
++ if (s) { s.delete(sub); if (!s.size) subs.delete(taskId); }
++ };
++ req.on('close', leave);
++ for (const e of await readBacklog()) write(sub, e);
++ if (await isEnded()) { leave(); res.end(); }
++ }
++
++ const status = () => ({ streams: [...subs.values()].reduce((n, s) => n + s.size, 0) });
++
++ return { publish, end, serve, status };
++}
++
++module.exports = { createTaskStream };
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
nnew file mode 100644
iindex 0000000..c96a962
---- /dev/null
++++ b/test/asset-cache.test.js
@@@ -0,0 +1,45 @@
++'use strict';
++/**
++ * The one cache rule Actor serves assets with (openvibe-shared/cache-policy, plan T11): a URL
++ * whose ?v= is the asset's current hash is immutable for a year, because those bytes can never
++ * change under the same URL; a wrong or missing ?v= may change at any moment, so it gets the
++ * short public window with a day of stale-while-revalidate. The values are pinned here against
++ * the module, not re-stated: the site never picks its own numbers.
++ */
++const assert = require('assert');
++const cache = require('openvibe-shared/cache-policy');
++const { assetVersion } = require('../server/render/layout');
++const { boot, check, done } = require('./helpers/boot');
++
++const ASSET = 'css/actor.css';
++const SHORT = 'public, max-age=300, stale-while-revalidate=86400';
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('the current ?v=<assetVersion> is immutable for a year', async () => {
++ const r = await t.get(`/${ASSET}?v=${assetVersion(ASSET)}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=31536000, immutable');
++ });
++
++ await check('a wrong-but-hex ?v= is never pinned', async () => {
++ const r = await t.get(`/${ASSET}?v=deadbeefdeadbeef`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), SHORT);
++ });
++
++ await check('no ?v= gets the short public window', async () => {
++ const r = await t.get(`/${ASSET}`);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('cache-control'), SHORT);
++ });
++
++ await check('those values are the module\'s, not Actor\'s own', () => {
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: true }), 'public, max-age=31536000, immutable');
++ assert.strictEqual(cache.assetHeaders(ASSET, { hashed: false }), SHORT);
++ assert.strictEqual(cache.htmlHeaders({ private: true }), 'private, no-store');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/auth-jwks.test.js b/test/auth-jwks.test.js
nnew file mode 100644
iindex 0000000..152ed0c
---- /dev/null
++++ b/test/auth-jwks.test.js
@@@ -0,0 +1,148 @@
++'use strict';
++/**
++ * The Network signing key is fetched and verified through openvibe-sdk/auth (plan T0/T1). The
++ * SDK keeps one client per URL: the last good keys through outages, exponential backoff, a
++ * rotation honoured at once, unknown-kid floods throttled. Actor is a thin shim around it: the
++ * boot reads the key and verifies a session/app token against the URL,
++ * a JWKS outage with cached keys still verifies,
++ * readiness (network_jwks) shows the cache state.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++function jwksServer({ doc = null, fail = null } = {}) {
++ const seen = [];
++ const srv = http.createServer((req, res) => {
++ seen.push(req.url);
++ if (fail) { res.writeHead(503); return res.end('down'); }
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(doc));
++ });
++ return new Promise((resolve) => {
++ srv.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${srv.address().port}`,
++ close: () => new Promise((r) => srv.close(r)),
++ seen,
++ setDoc: (d) => { doc = d; },
++ setFail: (f) => { fail = f; },
++ }));
++ });
++}
++
++function signJwt(claims, privateKey, { kid = null } = {}) {
++ const header = { alg: 'RS256', typ: 'JWT', ...(kid ? { kid } : {}) };
++ const input = `${Buffer.from(JSON.stringify(header)).toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
++}
++
++function pem(k) { return k.export({ type: 'spki', format: 'pem' }); }
++function jwk(k, kid) { return { ...k.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid }; }
++
++(async () => {
++ await check('a token signed by a key from a stub JWKS verifies', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore, jwksUrl } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: console });
++ const url = jwksUrl(config);
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `verify should succeed: ${v.reason || ''}`);
++ assert.strictEqual(v.claims.username, 'demo');
++ assert.ok(keys.get(), 'the cached PEM is exposed to the contracts guard');
++ assert.deepStrictEqual(jwks.seen, ['/api/.well-known/jwks']);
++ await jwks.close();
++ });
++
++ // A rotation publishes two keys: verification must use the one the token names, not the first.
++ await check('service tokens get the key their kid names (a rotation keeps two keys)', async () => {
++ const a = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const b = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(a.publicKey, 'old'), jwk(b.publicKey, 'new')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: { warn() {}, info() {} } });
++ const now = Math.floor(Date.now() / 1000);
++ const claims = { iss: config.networkIssuer, sub: 'app:x', aud: ['openvibe.actor'], iat: now, exp: now + 300 };
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, b.privateKey, { kid: 'new' })), pem(b.publicKey), 'the new key for a token signed with it');
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey, { kid: 'old' })), pem(a.publicKey));
++ assert.strictEqual(await keys.pemForToken(signJwt(claims, a.privateKey)), pem(a.publicKey), 'no kid: the first key');
++ assert.strictEqual(await keys.pemForToken('garbage'), pem(a.publicKey), 'an undecodable header falls back to the first key (the guard refuses the token)');
++ await jwks.close();
++ });
++
++ await check('with keys cached, a JWKS outage still verifies (the last good keys)', async () => {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const jwks = await jwksServer({ doc: { keys: [jwk(publicKey, 'k1')] } });
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: jwks.url, networkUrl: jwks.url, networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: console });
++ await keys.ensure();
++ const before = jwks.seen.length;
++ jwks.setFail(true); // Network goes away
++ const now = Math.floor(Date.now() / 1000);
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', username: 'demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey, { kid: 'k1' });
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, true, `outage should not break verification: ${v.reason || ''}`);
++ assert.strictEqual(jwks.seen.length, before, 'no new fetch during the outage');
++ await jwks.close();
++ });
++
++ await check('readiness (network_jwks) shows the SDK JWKS state', async () => {
++ const t = await boot();
++ const r = await t.get('/api/ready');
++ const b = r.json();
++ assert.strictEqual(b.checks.network_jwks.status, 'ok', `readiness JWKS not ok: ${JSON.stringify(b.checks.network_jwks)}`);
++ assert.ok(b.checks.network_jwks.detail && b.checks.network_jwks.detail.keys >= 1, `detail.keys should be >= 1: ${JSON.stringify(b.checks.network_jwks.detail)}`);
++ assert.strictEqual(b.checks.network_jwks.detail.failures, 0);
++ await t.close();
++ });
++
++ await check('an unreachable JWKS never names the URL or the fetch error (503, /api/ready)', async () => {
++ // Port 9 (discard) refuses instantly: the SDK's message is "JWKS http://127.0.0.1:9/…: connect ECONNREFUSED".
++ const { createKeyStore } = require('../server/auth/keys');
++ const config = { networkInternalUrl: 'http://127.0.0.1:9', networkUrl: 'http://127.0.0.1:9', networkIssuer: 'https://openvibe.network' };
++ const keys = createKeyStore({ config, log: { warn() {}, log() {}, info() {}, error() {} } });
++ await keys.ensure();
++ const now = Math.floor(Date.now() / 1000);
++ const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const token = signJwt({ iss: config.networkIssuer, sub: 'usr_demo', aud: ['openvibe.network'], iat: now, exp: now + 300 }, privateKey);
++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: 'openvibe.network' });
++ assert.strictEqual(v.ok, false);
++ assert.strictEqual(v.code, 'token.no_key', `the failure is token.no_key: ${v.code}`);
++ assert.strictEqual(v.reason, 'signing key not loaded yet', 'a fixed public reason, not the SDK text');
++ assert.ok(!/127\.0\.0\.1|jwks|ECONNREFUSED|fetch failed/i.test(v.reason), `reason leaks: ${v.reason}`);
++
++ // The same unreachable JWKS behind a booted Actor: neither sign-in nor /api/ready names it.
++ const t = await boot({ env: { OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:9', OV_NETWORK_URL: 'http://127.0.0.1:9' } });
++ const ready = await t.get('/api/ready');
++ const readyText = ready.text;
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(readyText),
++ `/api/ready leaks the JWKS URL or the fetch error: ${readyText.slice(0, 400)}`);
++ const me = await t.get('/auth/me', { cookie: `actor_at=${token}` });
++ assert.strictEqual(me.status, 401, `an unverifiable session token is 401: ${me.status} ${me.text.slice(0, 200)}`);
++ assert.ok(!/127\.0\.0\.1|\.well-known\/jwks|ECONNREFUSED|fetch failed/i.test(me.text),
++ `the sign-in answer leaks the JWKS URL or the fetch error: ${me.text.slice(0, 300)}`);
++ await t.close();
++ });
++
++ await check('an expired session token starts no session (the SDK takes the clock as a number)', async () => {
++ const t = await boot();
++ const u = t.network.addUser('expired-demo');
++ // Signed by the Network, but exp is an hour in the past: the SDK's Math.floor(now / 1000) must see it.
++ const expired = t.network.userToken(u, { ttl: -3600 });
++ const me = await t.get('/auth/me', { cookie: `actor_at=${expired}` });
++ assert.strictEqual(me.status, 401, `an expired cookie is not a session: ${me.status} ${me.text.slice(0, 200)}`);
++ const page = await t.get('/', { cookie: `actor_at=${expired}` });
++ assert.ok(page.status < 400, `a page still renders: ${page.status}`);
++ assert.ok(!page.text.includes('expired-demo'), 'the expired token signed nobody in');
++ await t.close();
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
\\ No newline at end of file
ddiff --git a/test/auth-ops.test.js b/test/auth-ops.test.js
nnew file mode 100644
iindex 0000000..8f7e31f
---- /dev/null
++++ b/test/auth-ops.test.js
@@@ -0,0 +1,101 @@
++'use strict';
++/**
++ * Sign-in with PKCE S256 (client id actor, cookies actor_at/actor_rt/actor_oauth), truthful readiness
++ * (db and agents required), /release.json, loopback-only /metrics, and pages useful without JavaScript.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++
++ await check('login redirects to Network with state and an S256 code challenge', async () => {
++ const r = await t.get('/auth/login?next=/tasks');
++ 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'), 'actor');
++ 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'), /actor_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=/tasks');
++ 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'), '/tasks');
++ const set = r.headers.get('set-cookie');
++ assert.match(set, /actor_at=[^;]+;.*HttpOnly/i);
++ assert.match(set, /actor_rt=[^;]+;.*HttpOnly/i);
++ const at = set.match(/actor_at=([^;]+)/)[1];
++ const me = await t.get('/auth/me', { cookie: `actor_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 or a verifier that does not match is refused', async () => {
++ const login = await t.get('/auth/login');
++ const loc = new URL(login.headers.get('location'));
++ const flowCookie = login.headers.get('set-cookie').split(';')[0];
++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
++ assert.strictEqual(bad.status, 400);
++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
++ assert.strictEqual(mismatch.status, 400, 'Network refuses the PKCE check; Actor reports it');
++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
++ assert.strictEqual(noCookie.status, 400);
++ });
++
++ await check('next= only accepts same-site paths', async () => {
++ const r = await t.get('/auth/login?next=//evil.example/x');
++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/actor_oauth=([^;]+)/)[1]);
++ assert.strictEqual(JSON.parse(flow).next, '/');
++ });
++
++ await check('readiness is truthful: required db + the agents; JWKS, the OAuth client and Valkey are 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.agents.status, 'ok');
++ assert.strictEqual(b.checks.agents.required, true);
++ assert.strictEqual(b.checks.network_jwks.status, 'ok');
++ assert.strictEqual(b.checks.oauth_client.status, 'ok');
++ assert.ok(!('docs' in b.checks) && !('events_relay' in b.checks), 'the console\'s checks moved with it');
++ });
++
++ 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, 'actor');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
++ assert.strictEqual(rel.json().metrics_url, '/release-metrics');
++ 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 (noscript nav, SSR footer)', async () => {
++ for (const p of ['/', '/agents', '/docs', '/updates']) {
++ 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 t.close();
++ done();
++})();
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
nnew file mode 100644
iindex 0000000..b3263e3
---- /dev/null
++++ b/test/caller-limits.test.js
@@@ -0,0 +1,112 @@
++'use strict';
++/**
++ * Per-caller rate limits (server/http/caller-limits.js, roadmap WS-R task 4): past its limit one caller
++ * gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
++ * another caller still passes; the window reopens on the clock. A person counts as themselves, anyone
++ * else by address. Actor's own budgets are pinned: actor.task.create 6 a minute, actor.route 30,
++ * actor.task.stream 30; reads take ACTOR_LIMITS_MINUTE/ACTOR_LIMITS_HOUR. Health, ready, release.json
++ * and metrics are never limited; refusals are logged (no token) and counted in actor_rate_limited_total.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { caller, BUDGETS } = require('../server/http/caller-limits');
++
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ // The limiter's clock: 15 s into a minute, so the minute window has 45 s left. Reads: 3 a minute.
++ let clock = Date.UTC(2026, 8, 27, 12, 0, 15);
++ const t = await boot({ callerLimits: true, limitsNow: () => clock, env: { ACTOR_LIMITS_MINUTE: '3', ACTOR_LIMITS_HOUR: '100' } });
++ const rosa = t.network.addUser('rosa');
++ const sam = t.network.addUser('sam');
++
++ await check('the budgets are pinned', () => {
++ assert.deepStrictEqual(BUDGETS, {
++ 'actor.task.create': { minute: 6, hour: 60 },
++ 'actor.route': { minute: 30, hour: 600 },
++ 'actor.task.stream': { minute: 30, hour: 600 },
++ });
++ });
++
++ await check('an agent-list read: 3 a minute per address, then 429 rate_limited with Retry-After; another address passes', async () => {
++ const from = (ip) => ({ headers: { 'x-forwarded-for': ip } });
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/agents', from('203.0.113.7'))).status, 200);
++ const r = await t.get('/api/v1/agents', from('203.0.113.7'));
++ assert.strictEqual(r.status, 429);
++ assert.match(r.headers.get('content-type'), /application\/problem\+json/);
++ assert.ok(Number(r.headers.get('retry-after')) > 0 && Number(r.headers.get('retry-after')) <= 45, r.headers.get('retry-after'));
++ const body = r.json();
++ assert.strictEqual(body.code, 'rate_limited');
++ assert.ok(body.detail.includes('actor.agent.read'), body.detail);
++ assert.strictEqual((await t.get('/api/v1/agents', from('203.0.113.8'))).status, 200, 'another address still passes');
++ });
++
++ await check('a signed-in person counts as themselves, not by address', async () => {
++ for (let i = 0; i < 3; i++) assert.strictEqual((await t.get('/api/v1/agents', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.7' } })).status, 200, 'rosa is not the address that was refused');
++ const r = await t.get('/api/v1/agents', { as: rosa, headers: { 'x-forwarded-for': '203.0.113.9' } });
++ assert.strictEqual(r.status, 429, 'rosa is over her own limit from any address');
++ assert.strictEqual((await t.get('/api/v1/agents', { as: sam, headers: { 'x-forwarded-for': '203.0.113.9' } })).status, 200, 'another person still passes');
++ });
++
++ await check('the next minute opens the window again', async () => {
++ clock += 60_000;
++ assert.strictEqual((await t.get('/api/v1/agents', { as: rosa })).status, 200);
++ });
++
++ await check('actor.task.create: 6 a minute per person (counted before the body is checked), the 7th refused; another person still creates', async () => {
++ for (let i = 0; i < 6; i++) assert.strictEqual((await t.get('/api/v1/tasks', { as: sam, json: {}, headers: SAME })).status, 422, `request ${i + 1} reaches the route`);
++ const r = await t.get('/api/v1/tasks', { as: sam, json: {}, headers: SAME });
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.json().code, 'rate_limited');
++ assert.ok(r.json().detail.includes('actor.task.create'), r.json().detail);
++ assert.strictEqual((await t.get('/api/v1/tasks', { as: rosa, json: {}, headers: SAME })).status, 422, 'another person still reaches the route');
++ });
++
++ await check('the router has its own number: 30 a minute, the 31st refused', async () => {
++ for (let i = 0; i < 30; i++) assert.strictEqual((await t.get('/api/v1/route', { json: { task: 'write a haiku' } })).status, 200);
++ const r = await t.get('/api/v1/route', { json: { task: 'write a haiku' } });
++ assert.strictEqual(r.status, 429);
++ assert.ok(r.json().detail.includes('actor.route'), r.json().detail);
++ assert.strictEqual((await t.get('/api/v1/route', { json: { task: 'write a haiku' }, headers: { 'x-forwarded-for': '203.0.113.50' } })).status, 200, 'another address still routes');
++ });
++
++ await check('the live stream has its own number: 30 a minute per person, the 31st refused', async () => {
++ const events = (u) => t.get('/api/v1/tasks/tsk_0000000000000000/events', { as: u });
++ for (let i = 0; i < 30; i++) assert.notStrictEqual((await events(rosa)).status, 429, `stream ${i + 1}`);
++ const r = await events(rosa);
++ assert.strictEqual(r.status, 429);
++ assert.ok(r.json().detail.includes('actor.task.stream'), r.json().detail);
++ assert.notStrictEqual((await events(sam)).status, 429, 'another person still streams');
++ });
++
++ await check('health, ready, release.json and metrics are never limited', async () => {
++ for (let i = 0; i < 6; i++) {
++ assert.strictEqual((await t.get('/api/health')).status, 200);
++ assert.notStrictEqual((await t.get('/api/ready')).status, 429);
++ assert.strictEqual((await t.get('/release.json')).status, 200);
++ assert.strictEqual((await t.get('/metrics')).status, 200);
++ }
++ });
++
++ await check('refusals are counted in actor_rate_limited_total and logged without a token', async () => {
++ const m = (await t.get('/metrics')).text;
++ const counted = m.split('\n').filter((l) => l.includes('actor_rate_limited_total')).join('\n');
++ assert.ok(/actor_rate_limited_total\{limit="actor.agent.read",window="minute"\} 2/.test(m), counted);
++ assert.ok(/actor_rate_limited_total\{limit="actor.task.create",window="minute"\} 1/.test(m), counted);
++ assert.ok(/actor_rate_limited_total\{limit="actor.route",window="minute"\} 1/.test(m), counted);
++ assert.ok(/actor_rate_limited_total\{limit="actor.task.stream",window="minute"\} 1/.test(m), counted);
++ const logs = t.logs();
++ assert.ok(logs.includes(`[Limits] actor.agent.read: user:${rosa.subject} refused`), 'one log line per refusal');
++ assert.ok(logs.includes(`[Limits] actor.task.create: user:${sam.subject} refused`), 'the create refusal is logged');
++ assert.ok(!/\[Limits\][^\n]*eyJ/.test(logs), 'a token in the log');
++ });
++
++ await check('who is counted', () => {
++ assert.strictEqual(caller({ principal: { requester: 'app:app_x' }, viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'app:app_x', 'a principal wins');
++ assert.strictEqual(caller({ viewer: { kind: 'user', subject: 'usr_a' }, ip: '203.0.113.1' }), 'user:usr_a');
++ assert.strictEqual(caller({ viewer: { kind: 'anonymous' }, ip: '198.51.100.4' }), 'ip:198.51.100.4');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
nnew file mode 100644
iindex 0000000..e72c46f
---- /dev/null
++++ b/test/contracts.test.js
@@@ -0,0 +1,131 @@
++'use strict';
++/**
++ * The released contracts (the pinned openvibe-contracts tag, 0.115.0 or later) describe what Actor does. The
++ * released `actor` service manifest and its four capability manifests are checked against the code: the domain,
++ * port, health and ready paths, the capabilities (each implemented by exactly the routes in server/http/api.js, each
++ * route guarded by the one capability it names), no events, and the shapes Actor answers with (the contracts'
++ * actor.agent-list-result@1, platform.placement-result@1 and platform.task@1).
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const contracts = require('openvibe-contracts');
++const configLib = require('../server/config');
++const { boot, check, done } = require('./helpers/boot');
++
++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];
++const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
++
++/** `METHOD /api/v1/path` for every route api.js registers, with the capability its guard names (null: public). */
++function codeRoutes() {
++ const out = new Map();
++ for (const m of API_SRC.matchAll(/^\s*r\.(get|post)\('([^']+)'(.*)$/gm)) {
++ const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
++ const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
++ out.set(`${m[1].toUpperCase()} /api/v1${m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
++ }
++ return out;
++}
++
++(async () => {
++ const manifest = contracts.services.get('actor');
++ const routes = codeRoutes();
++
++ await check('the actor service manifest is released as alpha, live, on openvibe.actor, with the contract range this pin satisfies', () => {
++ assert.ok(manifest, 'no actor manifest in openvibe-contracts');
++ assert.strictEqual(manifest.status, 'alpha');
++ assert.deepStrictEqual(manifest.domains, ['openvibe.actor']);
++ assert.strictEqual(manifest.publicOrigin, 'https://openvibe.actor');
++ assert.strictEqual(manifest.exposure.state, 'live');
++ assert.deepStrictEqual(manifest.namespacesOwned, ['actor.*']);
++ const range = manifest.contractRanges['openvibe-contracts'];
++ assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range), `pin ${require('openvibe-contracts/package.json').version} outside ${range}`);
++ assert.strictEqual(manifest.site.icon, 'actor');
++ });
++
++ await check('port, internal origin, health and ready paths are the ones the code serves', async () => {
++ const config = configLib.load({ NODE_ENV: 'test' });
++ assert.strictEqual(config.port, 4950);
++ assert.strictEqual(manifest.internalOrigin, `http://127.0.0.1:${config.port}`);
++ assert.strictEqual(manifest.health, '/api/health');
++ assert.strictEqual(manifest.ready, '/api/ready');
++ assert.ok(/openvibe-actor\.service/.test(manifest.notes) && /Port 4950/.test(manifest.notes), 'the notes name the unit and the port');
++ const t = await boot();
++ try {
++ assert.strictEqual((await t.get(manifest.health)).status, 200);
++ assert.strictEqual((await t.get(manifest.ready)).status, 200);
++ const rel = (await t.get('/release.json')).json();
++ assert.strictEqual(rel.service, 'actor');
++ assert.deepStrictEqual(contracts.validate('registry.release-manifest@1', rel).errors, []);
++ } finally { await t.close(); }
++ });
++
++ await check('the manifest lists exactly the four capabilities, all active, owned by actor, and the code guards exactly those', () => {
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());
++ for (const id of CAPS) {
++ const c = contracts.capabilities.get(id);
++ assert.ok(c, `${id} not released`);
++ assert.strictEqual(c.status, 'active', id);
++ assert.strictEqual(c.owner, 'actor', id);
++ }
++ const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');
++ const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z.]+)'/g).map((s) => s.slice(1, -1));
++ assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
++ });
++
++ await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
++ const declared = new Set();
++ for (const id of CAPS) {
++ const impl = contracts.capabilities.get(id).implementedBy;
++ assert.ok(impl && impl.length > 0, `${id} implementedBy is empty`);
++ for (const r of impl) {
++ declared.add(r);
++ assert.ok(routes.has(r), `${id} says ${r}, but api.js registers no such route`);
++ }
++ }
++ for (const r of routes.keys()) assert.ok(declared.has(r), `api.js registers ${r}, which no capability lists`);
++ });
++
++ await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
++ for (const id of CAPS) {
++ for (const r of contracts.capabilities.get(id).implementedBy) {
++ const route = routes.get(r);
++ if (id === 'actor.agent.read') {
++ assert.strictEqual(route.guard, null, `${r} must stay public`);
++ assert.strictEqual(contracts.capabilities.get(id).visibility, 'public');
++ } else {
++ assert.strictEqual(route.guard, id, `${r} is guarded by ${route.guard}, not ${id}`);
++ }
++ }
++ }
++ });
++
++ await check('no events: the manifest produces and consumes none, and the capabilities declare none', () => {
++ assert.deepStrictEqual(manifest.eventsProduced, []);
++ assert.deepStrictEqual(manifest.eventsConsumed, []);
++ for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);
++ });
++
++ await check('what the API answers is what the contracts describe: agents, placement and task', async () => {
++ const t = await boot();
++ try {
++ const kim = t.network.addUser('kim');
++ const agents = await t.get('/api/v1/agents');
++ assert.strictEqual(agents.status, 200);
++ assert.deepStrictEqual(contracts.validate('actor.agent-list-result@1', agents.json()).errors, []);
++ const routed = await t.get('/api/v1/route', { json: { task: 'Write a short poem about the sea', mode: 'cheapest' } });
++ assert.strictEqual(routed.status, 200, routed.text);
++ const body = routed.json();
++ const { class: _cls, ...placement } = body;
++ assert.deepStrictEqual(contracts.validate('platform.placement-result@1', placement).errors, [], 'the dry run is a placement result (plus its class)');
++ const req = { task: 'Write a short poem about the sea' };
++ assert.deepStrictEqual(contracts.validate('actor.task-create-request@1', req).errors, []);
++ const made = await t.get('/api/v1/tasks', { as: kim, json: req, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(made.status, 201, made.text);
++ assert.deepStrictEqual(contracts.validate('platform.task@1', made.json()).errors, []);
++ await t.waitFor(made.json().id);
++ } finally { await t.close(); }
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/discovery.test.js b/test/discovery.test.js
nnew file mode 100644
iindex 0000000..a812ceb
---- /dev/null
++++ b/test/discovery.test.js
@@@ -0,0 +1,89 @@
++'use strict';
++/**
++ * The public crawl artifacts (plan T11), fetched from the booted app: /robots.txt, /sitemap.xml,
++ * /llms.txt, /llms-full.txt and the home page's JSON-LD. Each is served with the right status and
++ * content type, carries at least one real entry, lists public pages only, and takes lastmod from the
++ * site's own data (STATUS.json) — never from the clock.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
++// llms.txt names the public machine endpoints (/api/v1/agents, /api/v1/route, /limits.json) on purpose, so only tasks and sign-in are private there.
++const PRIVATE = ['/tasks', '/auth/', '/api/', '/limits.json'];
++
++(async () => {
++ const t = await boot();
++ try {
++ await check('robots.txt: 200 text/plain, sign-in, the API and tasks disallowed, the public pages crawlable, sitemap named', async () => {
++ const r = await t.get('/robots.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ for (const d of ['/auth/', '/api/', '/tasks']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
++ for (const d of ['/agents', '/docs', '/updates']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable`);
++ assert.ok(r.text.includes('Sitemap: https://openvibe.actor/sitemap.xml'), 'sitemap not named');
++ assert.ok(/^User-agent: \*$/m.test(r.text), 'no User-agent: * group');
++ });
++
++ await check('sitemap.xml: 200 application/xml, real entries, lastmod from the data', async () => {
++ const r = await t.get('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^application\/xml/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ for (const p of ['/', '/agents', '/docs', '/updates']) assert.ok(r.text.includes(`<loc>https://openvibe.actor${p}</loc>`), `no ${p} entry`);
++ for (const p of ['/tasks', '/api/v1/agents', '/limits.json']) assert.ok(!r.text.includes(`<loc>https://openvibe.actor${p}</loc>`), `${p} is not a public page`);
++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
++ assert.ok(lastmods.length > 0, 'no lastmod anywhere');
++ assert.ok(lastmods.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)), `bad lastmod: ${lastmods.slice(0, 3)}`);
++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);
++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.actor${p}`), `private path in sitemap: ${p}`);
++ });
++
++ await check('llms.txt: 200 text/plain, real links, nothing private', async () => {
++ const r = await t.get('/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ assert.ok(r.text.startsWith('# OpenVibe.Actor'), 'no title');
++ assert.ok(r.text.includes('(https://openvibe.actor/agents)'), 'no agents link');
++ assert.ok(r.text.includes('(https://openvibe.actor/docs)'), 'no API link');
++ assert.ok(r.text.includes('(https://openvibe.services)'), 'no pointer to the developer platform');
++ assert.ok(r.text.includes('https://openvibe.actor/sitemap.xml'), 'sitemap not listed');
++ assert.ok(r.text.includes('(https://openvibe.actor/llms-full.txt)'), 'llms-full.txt not listed');
++ for (const p of ['/tasks', '/auth/']) assert.ok(!r.text.includes(`https://openvibe.actor${p}`), `private path in llms.txt: ${p}`);
++ });
++
++ await check('llms-full.txt: 200 text/plain, the site title, and every page the sitemap lists', async () => {
++ const r = await t.get('/llms-full.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/plain/);
++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
++ assert.ok(r.text.startsWith('# OpenVibe.Actor'), 'no title');
++ const sitemap = await t.get('/sitemap.xml');
++ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
++ assert.ok(locs.length >= 4, `sitemap too small: ${locs.length}`);
++ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
++ assert.ok(r.text.includes('URL: https://openvibe.actor/agents\n'), 'no /agents entry');
++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.actor${p}`), `private path in llms-full.txt: ${p}`);
++ assert.ok(!r.text.includes('https://openvibe.actor/tasks'), 'tasks path in llms-full.txt');
++ });
++
++ await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
++ const types = blocks.map((b) => b['@type']);
++ assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
++ assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);
++ const site = blocks.find((b) => b['@type'] === 'WebSite');
++ assert.strictEqual(site.url, 'https://openvibe.actor');
++ assert.strictEqual(site.name, 'OpenVibe.Actor');
++ const page = blocks.find((b) => b['@type'] === 'WebPage');
++ assert.strictEqual(page.url, 'https://openvibe.actor/');
++ });
++ } finally { await t.close(); }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..f06e10c
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -0,0 +1,104 @@
++'use strict';
++// Many requests per test file: the per-address limits would answer 429 part-way. The per-caller limits
++// (server/http/caller-limits.js) count nobody unless a test asks: boot({ callerLimits: true, limitsNow }).
++process.env.ACTOR_RATE_LIMIT_PER_MIN = process.env.ACTOR_RATE_LIMIT_PER_MIN || '100000';
++process.env.ACTOR_API_RATE_LIMIT_PER_MIN = process.env.ACTOR_API_RATE_LIMIT_PER_MIN || '100000';
++/**
++ * Boots Actor on a temp database with a mock Network and stand-in providers (test/helpers/providers.js), captures every
++ * log line, and returns a small HTTP client. Every test file gets its own.
++ *
++ * const t = await boot();
++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie })
++ * t.signIn(user) → cookie header value (actor_at = a Network user token, as after /auth/callback)
++ * t.waitFor(taskId, states) → the task row once it reaches one of the states
++ * t.providers (state, requests), t.logs(), t.dbDump()
++ */
++const http = require('http');
++const { startNetwork } = require('./mocks');
++const { startProviders } = require('./providers');
++
++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 providers = await startProviders();
++ const env = {
++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.actor', TRUST_PROXY: '1',
++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
++ OV_OAUTH_CLIENT_ID: 'actor', OV_OAUTH_CLIENT_SECRET: 'actor-secret', COOKIE_SECURE: 'false',
++ ...providers.env,
++ ...(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, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters });
++ 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) => `actor_at=${network.userToken(user)}`;
++
++ async function get(p, o = {}) {
++ const headers = { ...(o.headers || {}) };
++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
++ if (o.cookie) headers.cookie = o.cookie;
++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
++ let body = o.body;
++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
++ const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), 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); } };
++ }
++
++ /** Wait until a task reaches one of `states` (default: any end state); returns its row. */
++ async function waitFor(id, states = ['succeeded', 'failed', 'cancelled'], timeoutMs = 15_000) {
++ const until = Date.now() + timeoutMs;
++ for (;;) {
++ const row = await require('../../server/tasks/store').getTask(built.ctx.s, id);
++ if (row && states.includes(row.state)) return row;
++ if (Date.now() > until) throw new Error(`task ${id} stuck in ${row && row.state}`);
++ await new Promise((r) => setTimeout(r, 25));
++ }
++ }
++
++ 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, providers, config, ctx: built.ctx, get, signIn, waitFor, dbDump,
++ logs: () => captured.join('\n'),
++ async close() {
++ await built.ctx.engine.stop();
++ await new Promise((r) => server.close(r));
++ built.ctx.keys.client.stop();
++ await testdb.close();
++ await providers.close();
++ await network.close();
++ },
++ };
++ return t;
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
++}
++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..6a1d8f1
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,11 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
++ * ACTOR_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.ACTOR_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'actor', max });
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..91f22ea
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -0,0 +1,329 @@
++'use strict';
++/**
++ * An in-process stand-in for OpenVibe.Network (sign-in, JWKS, tokens), with a real RS256 key pair. Much of it served
++ * the developer console that moved to OpenVibe.Services; what Codes' tests use is sign-in and the JWKS.
++ *
++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `actor`, refresh_token,
++ * client_credentials for `actor` and for developer apps with Network's app rules:
++ * hashed secrets, sandbox audiences, cap = approved grants ∩ allowance for the audience);
++ * /oauth/revoke; /.well-known/openvibe; /api/v1/registry/services; and /api/v1/projects…
++ * with the same shapes, roles and problem codes as OpenVibe.Network's developer API
++ * (simplified: no audit paging, no rate limits). setDown(true) makes it refuse
++ * connections-equivalent (503) and drop() closes the socket.
++ * GET /api/v1/projects/:project/usage (owner/admin or staff; days 1-90, env all|sandbox|production)
++ * answers what setUsage() stored for the project, or an empty network.project-usage-result@1.
++ * POST /api/v1/projects/:project/export-tokens as Network mints them (owner/admin only,
++ * sub app:app_<project ULID>, read-only caps, purpose export), recorded in exportTokens.
++ * Every request is recorded in `.requests` so tests can assert what was (not) called.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth, ids, capabilities } = 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')}`;
++}
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
++
++async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.media'] } = {}) {
++ 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(), projects: new Map(), members: new Map(), apps: new Map(), creds: new Map(),
++ grants: new Map(), quotas: new Map(), usage: new Map(), audit: [], codes: new Map(), refresh: new Map(),
++ catalog: null, down: false, requests: [], tokenRequests: [], exportTokens: [], exportTtl: 300,
++ };
++ 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;
++ }
++
++ // ── Projects model ──
++ const roleOf = (p, u) => (st.members.get(p.id) || new Map()).get(u.subject) || null;
++ const projectView = (p, role) => ({
++ id: p.id, name: p.name, owner: { type: 'user', id: p.owner }, role: role || null, environment_policy: p.environment_policy,
++ environments: p.environment_policy === 'sandbox' ? ['sandbox'] : ['sandbox', 'production'], allowance: [...p.allowance],
++ created_at: p.created_at, archived_at: p.archived_at, counts: { members: (st.members.get(p.id) || new Map()).size, apps: [...st.apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },
++ });
++ const appView = (a) => ({
++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,
++ client_type: a.client_type, redirect_uris: a.redirect_uris, created_at: a.created_at, revoked_at: a.revoked_at,
++ grants: [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved').map((g) => g.capability).sort(),
++ });
++ function newCredential(app) {
++ const secret = `ovsec_${crypto.randomBytes(32).toString('base64url')}`;
++ const c = { id: ids.newId('project').replace(/^prj_/, 'crd_'), app_id: app.id, hash: sha256(secret), hint: secret.slice(-4), created_at: new Date().toISOString(), expires_at: null, revoked_at: null };
++ st.creds.set(c.id, c);
++ return { c, secret };
++ }
++ const credState = (c) => (c.revoked_at ? 'revoked' : (c.expires_at ? (Date.parse(c.expires_at) < Date.now() ? 'expired' : 'expiring') : 'active'));
++ const grantable = (id) => { const c = capabilities.get(id); return c && c.status === 'active' && ['public', 'partner'].includes(c.visibility); };
++ const defaultCatalog = () => capabilities.manifests.filter((c) => grantable(c.id)).map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));
++
++ async function projectsApi(req, raw, json, u, parts, q) {
++ let body = {};
++ if (raw.length) { try { body = JSON.parse(raw.toString('utf8')); } catch { return problem(json, 400, 'request.malformed_json', 'body is not valid JSON'); } }
++ const m = req.method;
++ if (parts.length === 1 && parts[0] === 'catalog' && m === 'GET') return json(200, { capabilities: st.catalog || defaultCatalog(), rule: 'only active public capabilities' });
++ if (!parts.length && m === 'GET') {
++ const list = [...st.projects.values()].filter((p) => roleOf(p, u) || (q.get('all') === '1' && u.role === 'admin')).map((p) => projectView(p, roleOf(p, u)));
++ return json(200, { projects: list });
++ }
++ if (!parts.length && m === 'POST') {
++ const name = String(body.name || '').trim();
++ if (!name || name.length > 80) return problem(json, 422, 'project.invalid', 'name must be 1-80 characters');
++ const p = { id: ids.newId('project'), name, owner: u.subject, environment_policy: 'sandbox', allowance: [], created_at: new Date().toISOString(), archived_at: null };
++ st.projects.set(p.id, p); st.members.set(p.id, new Map([[u.subject, 'owner']]));
++ return json(201, projectView(p, 'owner'));
++ }
++ const p = st.projects.get(parts[0]);
++ const role = p ? roleOf(p, u) : null;
++ const staff = u.role === 'admin';
++ if (!p || (!role && !staff)) return problem(json, 404, 'project.not_found', 'no such project');
++ const need = (r) => { if (!role || RANK[role] < RANK[r]) { problem(json, 403, 'project.forbidden', `requires ${r} role`); return false; } return true; };
++ const rest = parts.slice(1);
++ if (!rest.length && m === 'GET') return json(200, projectView(p, role));
++ if (rest[0] === 'archive' && m === 'POST') { if (role !== 'owner' && !staff) return problem(json, 403, 'project.forbidden', 'owner only'); p.archived_at = new Date().toISOString(); for (const a of st.apps.values()) if (a.project_id === p.id) a.revoked_at = a.revoked_at || p.archived_at; return json(200, projectView(p, role)); }
++ if (rest[0] === 'members') {
++ const mem = st.members.get(p.id);
++ if (rest.length === 1 && m === 'GET') return json(200, { members: [...mem.entries()].map(([s, r]) => { const x = [...st.users.values()].find((y) => y.subject === s); return { subject: { type: 'user', id: s }, username: x && x.username, display_name: x && x.display_name, role: r, added_at: p.created_at }; }) });
++ if (rest.length === 1 && m === 'POST') {
++ if (!need('admin')) return;
++ const target = st.byUsername.get(String(body.username || ''));
++ if (!target) return problem(json, 404, 'member.user_not_found', 'no such account');
++ if (!['admin', 'developer', 'viewer'].includes(body.role)) return problem(json, 422, 'member.invalid', 'role is one of admin, developer, viewer');
++ if (body.role === 'admin' && role !== 'owner') return problem(json, 403, 'member.forbidden', `a ${role} cannot assign admin`);
++ if (mem.has(target.subject)) return problem(json, 409, 'member.exists', 'already a member');
++ mem.set(target.subject, body.role);
++ return json(201, { subject: { type: 'user', id: target.subject }, role: body.role });
++ }
++ const s = rest[1];
++ if (!mem.has(s)) return problem(json, 404, 'member.not_found', 'not a member');
++ if (m === 'PATCH') { if (!need('admin')) return; mem.set(s, body.role); return json(200, { role: body.role }); }
++ if (m === 'DELETE') { if (s !== u.subject && !need('admin')) return; if (mem.get(s) === 'owner') return problem(json, 403, 'member.forbidden', 'the owner cannot be removed'); mem.delete(s); res204(json); return; }
++ }
++ if (rest[0] === 'quotas' && m === 'GET') return json(200, { quotas: [...st.quotas.values()].filter((x) => x.project_id === p.id).map((x) => ({ capability: x.capability, limit: x.limit, window: x.window, unit: x.unit, enforced_by: `openvibe.${capabilities.get(x.capability).owner}`, updated_at: x.updated_at })), note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
++ if (rest[0] === 'usage' && m === 'GET') {
++ if ((!role || RANK[role] < RANK.admin) && !staff) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const days = q.get('days') || '30';
++ const env = q.get('env') || 'all';
++ if (!/^\d{1,3}$/.test(days) || Number(days) < 1 || Number(days) > 90) return problem(json, 422, 'usage.invalid', 'days is a whole number from 1 to 90');
++ if (!['all', 'sandbox', 'production'].includes(env)) return problem(json, 422, 'usage.invalid', 'env is all, sandbox or production');
++ const today = new Date().toISOString().slice(0, 10);
++ const range = { days: Number(days), from: new Date(Date.now() - (Number(days) - 1) * 86400000).toISOString().slice(0, 10), to: today };
++ const u = st.usage.get(p.id);
++ if (u) return json(200, { ...u, project_id: p.id, env, range });
++ return json(200, { project_id: p.id, env, range, generated_at: new Date().toISOString(), last_recorded_at: null, freshness: 'No usage has been recorded for this project yet.', totals: [], daily: [], quotas: [], errors: { total: 0, by_code: [], recent: [] } });
++ }
++ if (rest[0] === 'audit' && m === 'GET') { if (!role || RANK[role] < RANK.admin) { if (!staff) return problem(json, 403, 'project.forbidden', 'requires admin role'); } return json(200, { entries: st.audit.filter((e) => e.project_id === p.id).slice().reverse(), next_before: null }); }
++ if (rest[0] === 'apps') {
++ if (rest.length === 1 && m === 'GET') return json(200, { apps: [...st.apps.values()].filter((a) => a.project_id === p.id).map(appView) });
++ if (rest.length === 1 && m === 'POST') {
++ const env = String(body.environment || 'sandbox');
++ if (!['sandbox', 'production'].includes(env)) return problem(json, 422, 'app.invalid', 'environment is sandbox or production');
++ if (!need(env === 'production' ? 'admin' : 'developer')) return;
++ if (env === 'production' && p.environment_policy === 'sandbox') return problem(json, 403, 'app.environment_not_allowed', 'this project may only have sandbox apps (staff enable production)');
++ const type = String(body.type || 'confidential');
++ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
++ for (const x of redirects) { try { const url = new URL(x); if (url.protocol !== 'https:' && url.hostname !== 'localhost') return problem(json, 422, 'app.invalid', 'redirect URIs must be https (http only for loopback)'); } catch { return problem(json, 422, 'app.invalid', `bad redirect URI ${x}`); } }
++ if (type === 'public' && !redirects.length) return problem(json, 422, 'app.invalid', 'a public app needs at least one redirect URI');
++ const a = { id: ids.newId('app'), project_id: p.id, name: String(body.name || '').trim() || 'app', environment: env, client_type: type, redirect_uris: redirects, created_at: new Date().toISOString(), revoked_at: null };
++ st.apps.set(a.id, a);
++ const out = appView(a);
++ if (type === 'confidential') { const { c, secret } = newCredential(a); out.credential = { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }; }
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'app.created', target: `app:${a.id}`, detail: { name: a.name }, event_type: 'network.app.created' });
++ return json(201, out);
++ }
++ const a = st.apps.get(rest[1]);
++ if (!a || a.project_id !== p.id) return problem(json, 404, 'app.not_found', 'no such app');
++ const manage = a.environment === 'production' ? 'admin' : 'developer';
++ if (rest.length === 2 && m === 'GET') return json(200, appView(a));
++ if (rest.length === 2 && m === 'PATCH') { if (!need(manage)) return; if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris; return json(200, appView(a)); }
++ if (rest.length === 2 && m === 'DELETE') { if (!staff && !need(manage)) return; a.revoked_at = new Date().toISOString(); return json(200, appView(a)); }
++ if (rest[2] === 'credentials') {
++ if (rest.length === 3 && m === 'GET') return json(200, { credentials: [...st.creds.values()].filter((c) => c.app_id === a.id).map((c) => ({ id: c.id, hint: c.hint, state: credState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at, last_used_at: null })) });
++ if (rest[3] === 'rotate' && m === 'POST') {
++ if (!need(manage)) return;
++ if (a.revoked_at) return problem(json, 409, 'app.revoked', 'app is revoked');
++ if (a.client_type !== 'confidential') return problem(json, 409, 'credential.public_client', 'public apps have no client secret');
++ const overlap = body.overlap_seconds === undefined ? 86400 : body.overlap_seconds;
++ const until = new Date(Date.now() + overlap * 1000).toISOString();
++ const prev = [];
++ for (const c of st.creds.values()) if (c.app_id === a.id && !c.revoked_at) { c.expires_at = until; prev.push({ id: c.id, expires_at: until }); }
++ const { c, secret } = newCredential(a);
++ return json(201, { credential: { id: c.id, client_secret: secret, hint: c.hint, shown_once: true }, previous: prev });
++ }
++ if (rest[4] === 'revoke' && m === 'POST') { if (!need(manage)) return; const c = st.creds.get(rest[3]); if (!c || c.app_id !== a.id) return problem(json, 404, 'credential.not_found', 'no such credential'); c.revoked_at = new Date().toISOString(); return json(200, { id: c.id, state: 'revoked' }); }
++ }
++ if (rest[2] === 'grants') {
++ const key = (cap) => `${a.id} ${cap}`;
++ const gv = (g) => ({ app_id: g.app_id, capability: g.capability, audience: g.audience, status: g.status, requested_by: g.requested_by, requested_at: g.requested_at, decided_by: g.decided_by || null, decided_at: g.decided_at || null });
++ if (rest.length === 3 && m === 'GET') return json(200, { grants: [...st.grants.values()].filter((g) => g.app_id === a.id).map(gv) });
++ if (rest.length === 3 && m === 'POST') {
++ if (!need('developer')) return;
++ const cap = String(body.capability || '');
++ const c = capabilities.get(cap);
++ if (!c) return problem(json, 422, 'grant.unknown_capability', `no capability ${cap} in the catalog`);
++ if (!grantable(cap)) return problem(json, 403, 'grant.not_grantable', `${cap} is ${c.visibility}; only public capabilities are granted to apps`);
++ const auto = RANK[role] >= RANK.admin && p.allowance.includes(cap);
++ const g = { app_id: a.id, capability: cap, audience: `openvibe.${c.owner}`, status: auto ? 'approved' : 'requested', requested_by: `user:${u.subject}`, requested_at: new Date().toISOString(), decided_at: auto ? new Date().toISOString() : null };
++ st.grants.set(key(cap), g);
++ return json(201, gv(g));
++ }
++ const g = st.grants.get(key(rest[3]));
++ if (!g) return problem(json, 404, 'grant.not_found', 'no such grant');
++ if (m === 'DELETE') { if (!staff && !need('admin')) return; g.status = 'revoked'; return json(200, gv(g)); }
++ if (rest[4] === 'approve') { if (!need('admin')) return; if (!p.allowance.includes(g.capability)) return problem(json, 403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance (staff set it)`); g.status = 'approved'; g.decided_at = new Date().toISOString(); return json(200, gv(g)); }
++ if (rest[4] === 'deny') { if (!need('admin')) return; g.status = 'denied'; return json(200, gv(g)); }
++ }
++ }
++ if (rest[0] === 'export-tokens' && rest.length === 1 && m === 'POST') {
++ if (!role || RANK[role] < RANK.admin) return problem(json, 403, 'project.forbidden', 'requires admin role');
++ const cap = { 'openvibe.media': ['media.object.list', 'media.object.read'], 'openvibe.events': ['events.app.read'] }[body.audience];
++ if (!cap) return problem(json, 422, 'export.invalid_audience', 'audience is one of openvibe.media, openvibe.events');
++ if (!['sandbox', 'production'].includes(body.env)) return problem(json, 422, 'export.invalid_env', 'env is one of sandbox, production');
++ const now = Math.floor(Date.now() / 1000);
++ const claims = {
++ iss: issuer, sub: `app:app_${p.id.replace(/^prj_/, '')}`, actor_type: 'app', aud: [body.audience], cap, ns: [p.id, `app.${p.id}.*`],
++ project_id: p.id, env: body.env, on_behalf_of: u.subject, purpose: 'export', iat: now, exp: now + st.exportTtl, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ };
++ const token = serviceAuth.signServiceToken(claims, privatePem);
++ st.exportTokens.push({ project_id: p.id, audience: body.audience, env: body.env, subject: u.subject, jti: claims.jti, token });
++ st.audit.push({ id: st.audit.length + 1, project_id: p.id, at: new Date().toISOString(), actor: `user:${u.subject}`, action: 'project.export_token_issued', target: claims.sub, detail: { audience: body.audience, env: body.env, cap, jti: claims.jti } });
++ return json(201, { access_token: token, token_type: 'Bearer', expires_in: st.exportTtl, expires_at: new Date(claims.exp * 1000).toISOString(), scope: cap.join(' '), audience: body.audience, env: body.env, purpose: 'export', subject: claims.sub, project_id: p.id, jti: claims.jti });
++ }
++ return problem(json, 404, 'route.not_found', 'no such route');
++ }
++ const res204 = (json) => json(204, {});
++
++ function mintApp(a, audience, cap) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, privatePem);
++ }
++
++ const srv = await listen(async (req, raw, json, res) => {
++ const url = new URL(req.url, 'http://x');
++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers });
++ 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 === '/api/v1/registry/services') {
++ return json(200, { services: [{ id: 'network', name: 'OpenVibe.Network', status: 'stable', domains: ['openvibe.network'], capabilities: [], runtime: { status: 'up', basis: 'ready', checked_at: new Date().toISOString() } }, { id: 'codes', name: 'OpenVibe.Codes', status: 'placeholder', domains: [], capabilities: [], runtime: { status: 'not-running', reason: 'placeholder', checked_at: new Date().toISOString() } }] });
++ }
++ 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 === 'actor') {
++ if (body.client_secret !== 'actor-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);
++ return json(200, { access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:actor', actor_type: 'service', aud: [body.audience || 'openvibe.events'], cap: String(body.scope || '').split(/\s+/).filter(Boolean), iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem), token_type: 'Bearer', expires_in: 300 });
++ }
++ }
++ const a = st.apps.get(body.client_id);
++ if (!a || a.revoked_at) return json(401, { error: 'invalid_client', error_description: 'unknown or revoked app' });
++ const cred = [...st.creds.values()].find((c) => c.app_id === a.id && c.hash === sha256(body.client_secret) && credState(c) !== 'revoked' && credState(c) !== 'expired');
++ if (!cred) return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
++ if (a.environment === 'sandbox' && !sandboxAudiences.includes(body.audience)) return json(400, { error: 'invalid_target', error_description: `${body.audience} does not accept sandbox tokens` });
++ const p = st.projects.get(a.project_id);
++ const held = [...st.grants.values()].filter((g) => g.app_id === a.id && g.status === 'approved' && g.audience === body.audience && p.allowance.includes(g.capability)).map((g) => g.capability);
++ const wanted = body.scope ? String(body.scope).split(/\s+/).filter(Boolean) : held;
++ const missing = wanted.filter((w) => !held.includes(w));
++ if (missing.length) return json(400, { error: 'invalid_scope', error_description: `not granted: ${missing.join(' ')}` });
++ if (!wanted.length) return json(400, { error: 'invalid_scope', error_description: `no grants for audience ${body.audience}` });
++ return json(200, { access_token: mintApp(a, body.audience, wanted), token_type: 'Bearer', expires_in: 300, scope: wanted.join(' ') });
++ }
++ if (url.pathname.startsWith('/api/v1/projects')) {
++ const u = userFrom(req);
++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'access token expired; refresh it');
++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
++ if (req.headers['x-internal-key']) return problem(json, 400, 'test.internal_key_seen', 'X-Internal-Key must never be sent');
++ const parts = url.pathname.replace(/^\/api\/v1\/projects\/?/, '').split('/').filter(Boolean).map(decodeURIComponent);
++ return await projectsApi(req, raw, json, u, parts, url.searchParams);
++ }
++ 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,
++ /** An authorization code the mock will exchange for this user if the verifier matches. */
++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
++ setAllowance(projectId, caps) { st.projects.get(projectId).allowance = caps; },
++ setEnvironmentPolicy(projectId, policy) { st.projects.get(projectId).environment_policy = policy; },
++ setUsage(projectId, body) { st.usage.set(projectId, body); },
++ setQuota(projectId, capability, limit, window, unit) { st.quotas.set(`${projectId} ${capability}`, { project_id: projectId, capability, limit, window, unit, updated_at: new Date().toISOString() }); },
++ setCatalog(list) { st.catalog = list; },
++ setDown(v) { st.down = v; },
++ mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
++ close: srv.close,
++ };
++}
++
++module.exports = { startNetwork, signJwt };
ddiff --git a/test/helpers/providers.js b/test/helpers/providers.js
nnew file mode 100644
iindex 0000000..8bd251d
---- /dev/null
++++ b/test/helpers/providers.js
@@@ -0,0 +1,122 @@
++'use strict';
++/**
++ * Stand-ins for everything Actor calls besides the Network, in one HTTP server, each under its own path prefix:
++ *
++ * /deepseek/chat/completions the runtime's model, the classifier and a checker (OpenAI-compatible, tool calls)
++ * /openai/chat/completions OpenAI's small checker model
++ * /openai/responses OpenAI's Responses agent with web search
++ * /local/chat/completions the open model on OpenVibe's server
++ * /tools/api/v1/tools[...] OpenVibe.Tools: the catalog, a tool's schema, a run
++ * /search/api/v1/search OpenVibe.Search
++ *
++ * Behaviour is set per test on `p.state`:
++ * cls what the classifier answers (default: the heuristic's guess is not used; 'answer')
++ * checkOk the checker's verdict (default true); a function (requestBody) → boolean also works
++ * runtime 'tool' (default: one run_tool mx call, then the answer), 'answer' (answer at once), 'fail' (HTTP 500),
++ * 'hang' (never answers)
++ * openai 'ok' (default) | 'fail'
++ * local 'ok' (default) | 'fail'
++ * `p.requests` lists { path, body } for every call, so a test can prove where a task's text went.
++ */
++const http = require('http');
++
++async function startProviders() {
++ const requests = [];
++ const state = { cls: 'answer', checkOk: true, runtime: 'tool', openai: 'ok', local: 'ok' };
++ const hanging = new Set();
++
++ const usage = (inp, out) => ({ prompt_tokens: inp, completion_tokens: out, prompt_cache_hit_tokens: 0 });
++ const chatReply = (res, message, u = usage(1000, 100)) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ model: 'stand-in', choices: [{ message: { role: 'assistant', ...message }, finish_reason: message.tool_calls ? 'tool_calls' : 'stop' }], usage: u })); };
++ const userText = (body) => { const m = [...(body.messages || [])].reverse().find((x) => x.role === 'user'); return m ? String(m.content) : ''; };
++
++ function judge(body, res) {
++ const sys = String(((body.messages || [])[0] || {}).content || '');
++ if (/sort one task/.test(sys)) return chatReply(res, { content: JSON.stringify({ class: state.cls }) }, usage(200, 10));
++ if (/check an AI agent's answer/.test(sys)) {
++ const ok = typeof state.checkOk === 'function' ? state.checkOk(body) : state.checkOk;
++ return chatReply(res, { content: JSON.stringify({ ok, reason: ok ? 'answers the task' : 'it answers a different question' }) }, usage(300, 20));
++ }
++ return null;
++ }
++
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (d) => { raw += d; });
++ req.on('end', () => {
++ let body = {};
++ try { body = raw ? JSON.parse(raw) : {}; } catch { body = {}; }
++ const url = new URL(req.url, 'http://x');
++ requests.push({ path: url.pathname, query: url.search, body, auth: req.headers.authorization || null });
++
++ if (url.pathname === '/deepseek/chat/completions') {
++ if (body.response_format) return judge(body, res);
++ if (state.runtime === 'fail') { res.writeHead(500, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: { message: 'stand-in model is down' } })); }
++ if (state.runtime === 'hang') { hanging.add(res); return undefined; }
++ const tools = (body.messages || []).filter((m) => m.role === 'tool');
++ if (state.runtime === 'tool' && !tools.length && body.tools) {
++ return chatReply(res, { content: 'Checking the mail records.', tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'run_tool', arguments: JSON.stringify({ tool: 'mx', input: { target: 'openvibe.network' } }) } }] });
++ }
++ return chatReply(res, { content: `Runtime answer to: ${userText(body)}${tools.length ? `\n\nTool said: ${tools[0].content.slice(0, 300)}` : ''}\n\nSources\n- OpenVibe.Tools mx` });
++ }
++ if (url.pathname === '/openai/chat/completions') {
++ const j = judge(body, res);
++ if (j !== null) return j;
++ return chatReply(res, { content: 'nano answer' });
++ }
++ if (url.pathname === '/openai/responses') {
++ if (state.openai === 'fail') { res.writeHead(429, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ error: { message: 'stand-in rate limit' } })); }
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({
++ status: 'completed', model: 'gpt-5-mini-stand-in',
++ output: [
++ { type: 'web_search_call', status: 'completed', action: { type: 'search', query: 'stand-in search' } },
++ { type: 'message', content: [{ type: 'output_text', text: `Web answer to: ${String(body.input).slice(0, 200)} (see https://example.org/source)`, annotations: [{ type: 'url_citation', url: 'https://example.org/source' }] }] },
++ ],
++ usage: { input_tokens: 2000, output_tokens: 200, input_tokens_details: { cached_tokens: 0 } },
++ }));
++ }
++ if (url.pathname === '/local/chat/completions') {
++ const j = judge(body, res);
++ if (j !== null) return j;
++ if (state.local === 'fail') { res.writeHead(500, { 'content-type': 'application/json' }); return res.end('{}'); }
++ return chatReply(res, { content: `Local answer to: ${userText(body)}` }, usage(100, 50));
++ }
++ if (url.pathname === '/tools/api/v1/tools') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ tools: [
++ { id: 'mx', name: 'MX Lookup', summary: 'Mail servers for a domain', keywords: ['mx records'], api: true, execution: 'sync', auth: { anonymous: true }, examples: [{ input: { target: 'example.com' } }] },
++ { id: 'yt', name: 'Video download', summary: 'A job tool', api: true, execution: 'job', auth: { anonymous: true } },
++ ] }));
++ }
++ if (url.pathname === '/tools/api/v1/tools/mx/schema') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ $defs: { input: { type: 'object', properties: { target: { type: 'string' } }, required: ['target'] } } }));
++ }
++ if (url.pathname === '/tools/api/v1/tools/mx/run') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ state: 'succeeded', tool: 'mx', result: { data: { target: (body.input || {}).target, records: { MX: [{ exchange: 'mailserver.example.com', priority: 1 }] } } } }));
++ }
++ if (url.pathname === '/search/api/v1/search') {
++ res.writeHead(200, { 'content-type': 'application/json' });
++ return res.end(JSON.stringify({ results: [{ title: 'A post', summary: 'about things', canonical_url: 'https://openvibe.community/p/x', type: 'post', owner: 'community' }] }));
++ }
++ res.writeHead(404); res.end();
++ return undefined;
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${server.address().port}`;
++ const env = {
++ ACTOR_DEEPSEEK_API_KEY: 'ds-test-key', ACTOR_DEEPSEEK_BASE_URL: `${url}/deepseek`,
++ ACTOR_OPENAI_API_KEY: 'oa-test-key', ACTOR_OPENAI_BASE_URL: `${url}/openai`,
++ ACTOR_LOCAL_LLM_URL: `${url}/local`, ACTOR_LOCAL_LLM_MODEL: 'tiny-open',
++ ACTOR_TOOLS_URL: `${url}/tools`, ACTOR_SEARCH_URL: `${url}/search`,
++ };
++ return {
++ url, env, state, requests,
++ reset() { Object.assign(state, { cls: 'answer', checkOk: true, runtime: 'tool', openai: 'ok', local: 'ok' }); requests.length = 0; },
++ close: () => new Promise((r) => { for (const res of hanging) { try { res.destroy(); } catch { /* gone */ } } server.close(r); }),
++ };
++}
++
++module.exports = { startProviders };
ddiff --git a/test/layout.test.js b/test/layout.test.js
nnew file mode 100644
iindex 0000000..53dd227
---- /dev/null
++++ b/test/layout.test.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * Every page carries the openvibe-shared boost marker and self-starting script (plan T11), so a
++ * same-site click swaps <main> in place without a reload. The marker names the layout's release,
++ * which the app sets from openvibe-shared/release (a swap only happens between pages of the same
++ * release; across a deploy it is a normal load), the boost tag names #main in data-main, and the
++ * shared navbar's sign-in follows the current page through the {path} template.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++const MARKER = /<meta name="ov-boost" content="(actor@[0-9A-Za-z._+-]{1,64})">/;
++const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#main" defer><\/script>/;
++
++(async () => {
++ const t = await boot();
++ const user = t.network.addUser('kim');
++ const release = (await t.get('/release.json')).json().release;
++
++ await check('every rendered page carries the ov-boost marker (the app\'s release) and the data-main boost script', async () => {
++ for (const p of ['/', '/agents', '/docs', '/updates']) {
++ const r = await t.get(p);
++ assert.strictEqual(r.status, 200, p);
++ const m = r.text.match(MARKER);
++ assert.ok(m, `${p}: no ov-boost marker`);
++ assert.strictEqual(m[1], `actor@${release}`, `${p}: the marker is not the app's release`);
++ assert.match(r.text, BOOST, `${p}: no boost script with data-main`);
++ assert.match(r.text, /<main id="main"/, `${p}: the page's main is not #main`);
++ }
++ const signedIn = await t.get('/tasks', { as: user });
++ assert.strictEqual(signedIn.status, 200);
++ assert.match(signedIn.text, MARKER, '/tasks signed in: no ov-boost marker');
++ assert.match(signedIn.text, BOOST, '/tasks signed in: no boost script');
++ });
++
++ await check('the home page head comes from openvibe-shared/shell with the same title, canonical, robots and JSON-LD', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ const head = r.text.slice(0, r.text.indexOf('</head>'));
++ assert.strictEqual((r.text.match(/<title>/g) || []).length, 1, 'exactly one <title>');
++ assert.ok(head.includes('<title>OpenVibe.Actor: your agent for everything</title>'), 'home title');
++ assert.ok(head.includes('<link rel="canonical" href="https://openvibe.actor/">'), 'canonical');
++ assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
++ const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
++ assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage'], 'the home JSON-LD');
++ for (const tag of ['<meta property="og:title"', 'data-ov-icon="actor"', '/css/actor.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
++ assert.ok(head.includes(tag), `head has ${tag}`);
++ }
++ assert.ok(r.text.includes('id="ov-footer"'), 'the server-rendered footer');
++ assert.ok(r.text.includes('OpenVibeFooter.init(window.__OV_PAGE.footer)'), 'the footer is initialised');
++ });
++
++ await check('the navbar config uses the {path} login template, not the path baked in', async () => {
++ const r = await t.get('/agents');
++ assert.match(r.text, /"loginUrl":"\/auth\/login\?next=\{path\}"/);
++ assert.ok(!/"loginUrl":"\/auth\/login\?next=%2F/.test(r.text), 'loginUrl still has the current path baked in');
++ });
++
++ await check('the agents page lists every catalog agent server-side, with no local path', async () => {
++ const r = await t.get('/agents');
++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ const agents = (await t.get('/api/v1/agents')).json().agents;
++ assert.ok(agents.length > 0, 'the catalog has agents');
++ for (const a of agents) {
++ assert.ok(r.text.includes(`<code>${a.id}</code>`), `${a.id} listed`);
++ assert.ok(r.text.includes(`id="${a.id}"`), `${a.id} has its anchor`);
++ }
++ assert.ok(!r.text.includes('/home/') && !r.text.includes('/mnt/'), 'no local filesystem path on the page');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/nginx-auth-limit.test.js b/test/nginx-auth-limit.test.js
nnew file mode 100644
iindex 0000000..7ee9c0c
---- /dev/null
++++ b/test/nginx-auth-limit.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++// deploy/nginx/openvibe.actor.conf: the shared navbar asks /auth/me on every page view, so the session probe has its own location on
++// the API zone; the sign-in routes keep the strict zone (10 a minute). Both answer 429 when limited, not
++// nginx's default 503 (the browser check, OpenVibe.Host scripts/browser-check.js, found /auth/me answering
++// 503 after about ten quick page loads, which the navbar reads as signed out).
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvibe.actor.conf'.split('/')), 'utf8');
++const block = (loc) => { const m = conf.match(new RegExp(`\\n location ${loc} \\{\\n([\\s\\S]*?)\\n \\}`)); return m && m[1]; };
++const probe = block('= /auth/me'), auth = block('/auth/');
++assert.ok(probe && auth, 'both locations');
++assert.ok(conf.indexOf('location = /auth/me') < conf.indexOf('location /auth/ {'));
++assert.match(probe, /limit_req zone=ovactor_api burst=30 nodelay;/);
++assert.match(auth, /limit_req zone=ovactor_auth burst=10 nodelay;/);
++for (const b of [probe, auth]) assert.match(b, /limit_req_status 429;/);
++const upstream = (b) => (b.match(/proxy_pass (\S+);/) || [])[1];
++assert.ok(upstream(probe) && upstream(probe) === upstream(auth), 'the same upstream');
++console.log('nginx auth limit: /auth/me on the API zone, 429 when limited');
ddiff --git a/test/no-internal-key.test.js b/test/no-internal-key.test.js
nnew file mode 100644
iindex 0000000..ad9d65b
---- /dev/null
++++ b/test/no-internal-key.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * No X-Internal-Key anywhere (ADR-014: "No developer-facing path ever accepts X-Internal-Key").
++ * Code, client scripts, deploy files, the environment template and the manifest must not
++ * mention it; the running Actor never sends it to Network (the Network mock refuses any request
++ * that carries one), and a request that presents one gets no special treatment.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/boot');
++
++const ROOT = path.join(__dirname, '..');
++const PATTERN = /x-internal-key|internal[_-]?key|INTERNAL_API_KEY/i;
++
++function files(dir) {
++ const out = [];
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ if (e.name === 'node_modules' || e.name.startsWith('.git')) continue;
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) out.push(...files(p)); else out.push(p);
++ }
++ return out;
++}
++
++(async () => {
++ await check('no mention in server/, public/, deploy/, docs/*.json, .env.example, package.json or CI', async () => {
++ const targets = [
++ ...files(path.join(ROOT, 'server')), ...files(path.join(ROOT, 'public')), ...files(path.join(ROOT, 'deploy')),
++ ...(fs.existsSync(path.join(ROOT, 'docs')) ? files(path.join(ROOT, 'docs')).filter((f) => f.endsWith('.json')) : []),
++ path.join(ROOT, '.env.example'), path.join(ROOT, 'package.json'), path.join(ROOT, '.github', 'workflows', 'ci.yml'),
++ ].filter((f) => fs.existsSync(f));
++ assert.ok(targets.length > 20);
++ const hits = targets.filter((f) => PATTERN.test(fs.readFileSync(f, 'utf8')));
++ assert.deepStrictEqual(hits.map((f) => path.relative(ROOT, f)), []);
++ });
++
++ const t = await boot();
++ await check('a whole session (sign-in, every page, the router) never sends an internal key to Network', async () => {
++ const u = t.network.addUser('nia');
++ for (const p of ['/', '/agents', '/docs', '/tasks', '/updates', '/auth/me']) await t.get(p, { as: u });
++ await t.get('/api/v1/route', { as: u, json: { task: 'write a haiku' } });
++ const sent = t.network.requests.filter((r) => r.headers['x-internal-key']);
++ assert.strictEqual(sent.length, 0);
++ });
++
++ await check('presenting X-Internal-Key changes nothing: the public routes answer as for anyone', async () => {
++ const r = await t.get('/api/v1/agents', { headers: { 'x-internal-key': 'anything' } });
++ assert.strictEqual(r.status, 200);
++ const me = await t.get('/auth/me', { headers: { 'x-internal-key': 'anything' } });
++ assert.deepStrictEqual(JSON.parse(me.text), { user: null }, 'the key signs nobody in');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/open-redirect.test.js b/test/open-redirect.test.js
nnew file mode 100644
iindex 0000000..175c4fe
---- /dev/null
++++ b/test/open-redirect.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++// Open redirect regression (WS-R task 5): the sign-in "next" never leaves the site. Browsers drop tab and
++// newline characters from a URL and read a backslash as "/", so "/<TAB>/evil.com" is "//evil.com" to them;
++// until 2026-09-26 it got through here.
++const assert = require('assert');
++const { sanitizeNext } = require('../server/auth/sso');
++
++const config = { baseUrl: 'https://site.openvibe.test', networkUrl: 'https://openvibe.network' };
++const home = (v) => {
++ const out = String(sanitizeNext(v, config));
++ const host = new URL(out.replace(/[\t\n\r]/g, ''), 'https://site.openvibe.test').host;
++ return host === 'site.openvibe.test' || host === 'openvibe.network';
++};
++for (const v of ['//evil.com', '/\\evil.com', '\\\\evil.com', 'https://evil.com', 'http:evil.com', '/\t/evil.com', '/\n/evil.com', '\t//evil.com',
++ ' //evil.com', '/\\/evil.com', 'https:/\\evil.com', '///evil.com', 'javascript:alert(1)', '/%09/evil.com']) {
++ assert.ok(home(v), `${JSON.stringify(v)} stays on the site (got ${JSON.stringify(sanitizeNext(v, config))})`);
++ assert.ok(!/^\s*javascript:/i.test(String(sanitizeNext(v, config))), JSON.stringify(v));
++}
++assert.strictEqual(sanitizeNext('/settings?tab=a', config), '/settings?tab=a', 'a plain relative path is kept');
++console.log('open redirect: all checks passed');
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
nnew file mode 100644
iindex 0000000..8be62b8
---- /dev/null
++++ b/test/perf-budget.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++// Size budgets for openvibe.actor's home page (roadmap WS-T task 1, openvibe-shared/perf-budget): the
++// server as it runs (a fresh database), measured without a browser. Budgets sit about 10% above the
++// 2026-10-08 measurement (see below); raising one is a decision to state in the commit.
++// node test/perf-budget.test.js
++const assert = require('assert');
++const fs = require('fs');
++const net = require('net');
++const os = require('os');
++const path = require('path');
++const { spawn } = require('child_process');
++const { measure, check, format } = require('openvibe-shared/perf-budget');
++
++// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
++// headroom; file counts are the measured counts and external files stay at 0.
++const BUDGETS = {
++ htmlRawKB: 39.5, // measured 35.8 (the guest task box lists example tasks)
++ htmlBrotliKB: 9.5, // 8.6
++ jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
++ jsRawKB: 264, // 239.8
++ jsBrotliKB: 62.5, // 56.5
++ cssFiles: 2, // 2 (actor.css + the cached /shared/showcase.css)
++ cssRawKB: 22.5, // 20.2
++ cssBrotliKB: 5.2, // 4.7
++ externalFiles: 0, // 0
++};
++
++const freePort = () => new Promise((resolve) => { const s = net.createServer(); s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); }); });
++
++(async () => {
++ // A database of its own (not the shared dev PGlite in data/pglite, and not whatever the caller's
++ // DATABASE_URL names), so the measurement is the server on a fresh database and the run leaves nothing.
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-budget-'));
++ const pgliteDir = path.join(dir, 'pglite');
++ const port = await freePort();
++ const child = spawn(process.execPath, [path.join(__dirname, '..', 'server', 'index.js')], {
++ cwd: path.join(__dirname, '..'),
++ env: {
++ ...process.env, PORT: String(port), HOST: '127.0.0.1', NODE_ENV: 'test',
++ DATABASE_URL: '', DATABASE_DIRECT_URL: '', VALKEY_URL: '', ACTOR_PGLITE_DIR: pgliteDir,
++ },
++ stdio: ['ignore', 'ignore', 'pipe'],
++ });
++ let stderr = '';
++ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
++ const base = `http://127.0.0.1:${port}`;
++ try {
++ let up = false;
++ for (let i = 0; i < 600 && !up; i++) {
++ up = await fetch(`${base}/api/health`).then((r) => r.ok).catch(() => false);
++ if (!up) await new Promise((r) => setTimeout(r, 100));
++ }
++ assert.ok(up, `the server did not start:\n${stderr}`);
++ assert.ok(fs.existsSync(pgliteDir), 'the server did not use the isolated database (ACTOR_PGLITE_DIR)');
++ const m = await measure({ base });
++ const over = check(m, BUDGETS);
++ assert.deepStrictEqual(over, [], format(m, over));
++ console.log(format(m));
++ console.log('perf budget: all checks passed');
++ } finally {
++ child.kill();
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..93d4d02
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,24 @@
++#!/usr/bin/env node
++/**
++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite
++ * databases and in-process mocks of OpenVibe.Network, Events and Media; none needs the
++ * network or a running site.
++ *
++ * npm test # everything
++ * npm test -- schedule # only files whose name contains one of the words
++ * npm test -- --strict # a skipped test fails the run too
++ *
++ * A test that cannot run something here prints `<label>: skipped (<why>)`: that file is listed with
++ * ○ and not counted as passed (openvibe-shared/test-runner).
++ */
++'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: 60000, pad: 34, parallel: 1 }).then((r) => {
++ if (process.env.VERBOSE) {
++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
++ console.log(`\n${r.summary}`);
++ }
++ process.exit(r.exitCode);
++}, (err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..8271b5c
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
++ * Wiki app (walking Express's router stack, so a route added later is crawled without anyone
++ * listing it), fills route parameters with seeded values, and GETs each path as several people,
++ * reporting any response whose body or headers carry a value that person must never see.
++ * Not a test itself (no .test.js); used with test/helpers/boot.js.
++ */
++
++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
++function mountPath(layer) {
++ if (!layer.regexp || layer.regexp.fast_slash) return '';
++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
++ let i = 0;
++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
++}
++
++/** Every route of the booted Codes app (captured from createApp): [{ path, methods }]. */
++function listRoutes(app) {
++ if (!app || !app._router) throw new Error('no Express app');
++ const out = [];
++ const walk = (stack, prefix) => {
++ for (const layer of stack) {
++ if (layer.route) {
++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
++ const mp = mountPath(layer);
++ if (mp !== null) walk(layer.handle.stack, prefix + mp);
++ } else {
++ const mp = mountPath(layer);
++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
++ }
++ }
++ };
++ walk(app._router.stack, '');
++ const seen = new Set();
++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
++}
++
++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
++function expand(template, values) {
++ const names = [];
++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
++ if (!names.length) return [t];
++ const lists = names.map((n) => values(n));
++ const width = Math.max(...lists.map((l) => l.length));
++ const paths = new Set();
++ for (let i = 0; i < width; i++) {
++ let j = 0;
++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
++ }
++ return [...paths];
++}
++
++/** Every GET path: each GET route expanded, also with `query` appended, plus `extra`. */
++function getPaths(app, values, { query = '', extra = [] } = {}) {
++ const paths = new Set();
++ for (const r of listRoutes(app)) {
++ if (!r.methods.includes('get') && !r.methods.includes('_all')) continue;
++ for (const p of expand(r.path, values)) {
++ paths.add(p);
++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
++ }
++ }
++ for (const p of extra) paths.add(p);
++ return [...paths];
++}
++
++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
++function leaks(res, needles) {
++ const found = [];
++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
++ for (const [label, value] of Object.entries(needles)) {
++ if (!value) continue;
++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
++ if (headerText.includes(value)) found.push({ label, where: 'headers' });
++ }
++ return found;
++}
++
++/** A fresh client address per request (TRUST_PROXY is on in tests): the per-address rate limits are not what is being read. */
++let seq = 0;
++function nextAddress() { seq++; return `10.${(seq >> 16) & 255}.${(seq >> 8) & 255}.${seq & 255}`; }
++
++/**
++ * GET every path as every person ({ who: user | token | null }) through t.get (a user object signs
++ * in with the session cookie, a string is a bearer token). `needlesFor(who)` names what that person
++ * must never see. Resolves { found, answered, statuses }.
++ */
++async function crawl(t, paths, people, needlesFor, { concurrency = 6 } = {}) {
++ const found = [];
++ const statuses = {};
++ let answered = 0;
++ for (const [who, as] of Object.entries(people)) {
++ const needles = needlesFor(who);
++ const queue = [...paths];
++ await Promise.all(Array.from({ length: concurrency }, async () => {
++ while (queue.length) {
++ const p = queue.shift();
++ let r;
++ try { r = await t.get(p, { ...(as ? { as } : {}), headers: { 'x-forwarded-for': nextAddress() } }); } catch (e) { r = { status: 0, text: '', headers: {} }; }
++ if (r.status) answered++;
++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
++ statuses[cls] = (statuses[cls] || 0) + 1;
++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
++ }
++ }));
++ }
++ return { found, answered, statuses };
++}
++
++module.exports = { listRoutes, expand, getPaths, leaks, crawl, nextAddress };
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..8afb30a
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++/**
++ * Codes' own secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
++ * internal-secret class). Codes' environment holds one: its Network OAuth client secret, a sentinel here. Every route
++ * the booted app has (listed from Express's router stack) is requested as anonymous, a signed-in person and a Network
++ * admin with real and nonsense ids, plus the sign-in callback with a forged code (it posts the client secret to
++ * Network), the developer console's old addresses (now redirects), unknown paths and every write route with a broken
++ * body. No body or header may carry it, nor may the database or the captured log lines.
++ *
++ * node test/security-secrets.test.js
++ */
++const assert = require('assert');
++const appModule = require('../server/app');
++let capturedApp = null;
++const realCreate = appModule.createApp;
++appModule.createApp = async (...a) => { const built = await realCreate(...a); capturedApp = built.app; return built; };
++const { boot, check, done } = require('./helpers/boot');
++const { getPaths, crawl, listRoutes, expand, leaks, nextAddress } = require('./security-crawl');
++
++const SECRETS = { OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-codes-oauth-client' };
++
++(async () => {
++ const t = await boot({ env: SECRETS });
++ const dev = t.network.addUser('dev');
++ const staff = t.network.addUser('root', { role: 'admin' });
++
++ await check('every GET route, as three people, with real and nonsense ids: no sentinel', async () => {
++ const nonsense = ['nope', "'\"<x>", 'x'.repeat(300)];
++ const values = () => ['claude-code', ...nonsense];
++ const paths = getPaths(capturedApp, values, {
++ query: 'q=x&code=forged&state=forged&next=%2F',
++ extra: ['/api/health', '/api/ready', '/metrics', '/release.json', '/auth/callback?code=forged&state=forged', '/callback?code=forged&state=forged', '/api/nope', '/nope', '/.env', '/api/%',
++ '/projects', '/docs/api', '/oauth/test-callback?code=x', '/api/v1/releases/rel_x', '/docs/harnesses'],
++ });
++ const r = await crawl(t, paths, { anonymous: null, dev, staff }, () => SECRETS);
++ assert.ok(r.answered >= paths.length * 2, `${r.answered} answers`);
++ assert.deepStrictEqual(r.found, []);
++ });
++
++ await check('every write route with a broken body, anonymous and as the developer: no sentinel', async () => {
++ const found = [];
++ const values = () => ['x'];
++ for (const route of listRoutes(capturedApp)) {
++ for (const method of route.methods.filter((m) => ['post', 'put', 'patch', 'delete'].includes(m))) {
++ for (const p of expand(route.path, values)) {
++ for (const as of [null, dev]) {
++ const r = await t.get(p, { method: method.toUpperCase(), ...(as ? { as } : {}), body: '{"broken": ', headers: { 'content-type': 'application/json', 'x-forwarded-for': nextAddress() } });
++ for (const l of leaks(r, SECRETS)) found.push(`${method} ${p} → ${r.status} carries ${l.label}`);
++ }
++ }
++ }
++ }
++ assert.deepStrictEqual(found, []);
++ });
++
++ await check('the database and the captured log lines carry no sentinel', async () => {
++ const text = await t.dbDump() + t.logs();
++ for (const [k, v] of Object.entries(SECRETS)) assert.ok(!text.includes(v), `${k} in the database or the logs`);
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-session.test.js b/test/security-session.test.js
nnew file mode 100644
iindex 0000000..a1f1150
---- /dev/null
++++ b/test/security-session.test.js
@@@ -0,0 +1,69 @@
++'use strict';
++/**
++ * The actor_at session cookie must hold a Network SESSION token (audience openvibe.network, a
++ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =
++ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal
++ * tokens with the same key and issuer; none of them is a Actor session.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { signJwt } = require('./helpers/mocks');
++
++(async () => {
++ const s = await boot();
++ const staff = s.network.addUser('stafferson');
++ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
++
++ const now = () => Math.floor(Date.now() / 1000);
++ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.
++ const fedcm = () => signJwt({
++ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,
++ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,
++ }, s.network.privatePem);
++
++ await check('a FedCM assertion is not an Actor session (it signs nobody in)', async () => {
++ const cookie = `actor_at=${fedcm()}`;
++ const me = await s.get('/auth/me', { cookie });
++ assert.strictEqual(me.status, 401, 'an assertion for another site is not a session here');
++ const page = await s.get('/', { cookie });
++ assert.ok(!page.text.includes(`Signed in as <strong>${staff.username}`), 'the page shows nobody signed in');
++ });
++
++ await check('an app token is not an Actor session', async () => {
++ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
++ const me = await s.get('/auth/me', { cookie: `actor_at=${token}` });
++ assert.strictEqual(me.status, 401);
++ });
++
++ await check('/auth/me: a guest is signed out (200 { user: null }); a session cookie that fails is 401', async () => {
++ const guest = await s.get('/auth/me');
++ assert.strictEqual(guest.status, 200, 'no session cookie at all: not an error');
++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await s.get('/auth/me', { cookie: 'actor_at=garbage' })).status, 401, 'a present but invalid access cookie');
++ assert.strictEqual((await s.get('/auth/me', { cookie: 'actor_rt=stale' })).status, 401, 'a refresh cookie alone is a session that did not resolve');
++ assert.strictEqual((await s.get('/auth/me', { as: staff })).status, 200);
++ });
++
++ await check('a real Network session token signs the person in', async () => {
++ const me = await s.get('/auth/me', { as: staff });
++ assert.strictEqual(me.status, 200, me.text.slice(0, 200));
++ assert.strictEqual(JSON.parse(me.text).user.username, staff.username);
++ });
++
++ await check('another site cannot sign a person out; this site can', async () => {
++ const cross = await s.get('/auth/logout', { as: staff, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 200, 'a confirm page, not a sign-out');
++ assert.ok(!/actor_at=;|actor_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')), 'the session cookie is kept');
++ assert.match(cross.text, /method="post"/);
++ const crossPost = await s.get('/auth/logout', { as: staff, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(crossPost.status, 403);
++ const same = await s.get('/auth/logout?next=/tasks', { as: staff, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(same.headers.get('location'), '/tasks');
++ assert.match(String(same.headers.get('set-cookie') || ''), /actor_at=;/);
++ });
++
++ await s.close();
++ done();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
nnew file mode 100644
iindex 0000000..2813428
---- /dev/null
++++ b/test/security-ssrf.test.js
@@@ -0,0 +1,130 @@
++'use strict';
++/**
++ * Actor itself never fetches a URL that came from a request or a task (roadmap WS-R task 5, the SSRF class). A task is
++ * text for a model: a URL in it is read by the model, and a page is read only by asking OpenVibe.Tools (whose own guard
++ * decides what it may fetch). So every outbound request of the process is recorded while tasks, dry runs and sign-in
++ * full of internal addresses in every spelling go through it, and each must go to a configured base only: the Network,
++ * the model providers, OpenVibe.Tools or OpenVibe.Search, never to an address a caller typed. A model that invents a tool
++ * id (or a URL for one) reaches nothing. And a ratchet: every file in server/ that makes an outbound request itself is on
++ * a reviewed list with where it goes.
++ *
++ * node test/security-ssrf.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const outbound = [];
++const realFetch = globalThis.fetch;
++let testClient = null; // the test's own requests to the booted app are not Actor's
++globalThis.fetch = (url, opts) => {
++ const u = String((url && url.url) || url);
++ if (!testClient || !u.startsWith(testClient)) outbound.push(u);
++ return realFetch(url, opts);
++};
++
++const { boot, check, done } = require('./helpers/boot');
++const { createOpenVibeTools } = require('../server/agents/tools');
++
++const PROBE = '/ssrf-probe-path';
++const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`, `http://0x7f000001${PROBE}`, `http://[::1]${PROBE}`, `http://[::ffff:127.0.0.1]${PROBE}`,
++ `http://169.254.169.254/latest/meta-data${PROBE}`, `http://10.0.0.1${PROBE}`, `http://localhost:4001${PROBE}`, `file:///etc/passwd${PROBE}`, `gopher://127.0.0.1:6379/_x${PROBE}`];
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot();
++ testClient = t.base;
++ const dev = t.network.addUser('dev');
++ // The only hosts Actor may call: the Network (sign-in), the stand-in providers, Tools and Search (the last three share one stand-in server).
++ const ALLOWED = [t.network.url, t.providers.url];
++ const configured = (u) => ALLOWED.some((base) => u === base || u.startsWith(`${base}/`));
++
++ await check('tasks and dry runs full of internal URLs, in every class and mode, fetch only the configured bases', async () => {
++ outbound.length = 0;
++ t.providers.reset();
++ for (const cls of ['answer', 'lookup', 'research', 'web', 'code']) {
++ t.providers.state.cls = cls;
++ for (const u of INTERNAL) {
++ const task = `Read ${u} and tell me what it says. Also fetch ${u}?x=1 and ${u.replace(/^\w+:\/\//, '//')}`;
++ const r = await t.get('/api/v1/tasks', { as: dev, json: { task, mode: cls === 'answer' ? 'private' : 'balanced' }, headers: SAME });
++ assert.ok([201, 402, 422, 429].includes(r.status), `${cls} ${u}: ${r.status} ${r.text.slice(0, 200)}`);
++ if (r.status === 201) await t.waitFor(r.json().id);
++ await t.get('/api/v1/route', { json: { task, mode: 'best', agent: u } });
++ await t.get('/api/v1/route', { json: { task, mode: u } });
++ }
++ }
++ assert.ok(outbound.length > 0, 'the tasks made outbound calls at all (the providers and Tools)');
++ const stray = outbound.filter((u) => !configured(u));
++ assert.deepStrictEqual(stray, [], 'Actor called an address that is not a configured base');
++ assert.deepStrictEqual(outbound.filter((u) => u.includes(PROBE) || u.includes('ssrf-probe')), [], 'a typed URL (or any part of it) reached a request address');
++ const reached = t.providers.requests.filter((q) => q.path.includes(PROBE) || q.query.includes(PROBE));
++ assert.deepStrictEqual(reached.map((q) => q.path), [], 'a typed URL became a path or query on a provider, Tools or Search');
++ });
++
++ await check('sign-in next=, task ids and bearer tokens full of internal URLs are echoed or refused, never requested', async () => {
++ outbound.length = 0;
++ for (const u of INTERNAL) {
++ const login = await t.get(`/auth/login?next=${encodeURIComponent(u)}`);
++ assert.strictEqual(login.status, 302);
++ await t.get(`/tasks/${encodeURIComponent(u)}`, { as: dev });
++ await t.get(`/api/v1/tasks/${encodeURIComponent(u)}`, { as: dev });
++ await t.get(`/api/v1/tasks/${encodeURIComponent(u)}/events`, { as: dev });
++ await t.get('/api/v1/tasks', { bearer: u });
++ }
++ assert.deepStrictEqual(outbound.filter((x) => x.includes(PROBE) || !configured(x)), [], 'a typed URL was requested');
++ });
++
++ await check('the model\'s own tool calls cannot make Actor fetch a URL: only Tools\' catalog ids run, and read_page hands the URL to Tools', async () => {
++ const seen = [];
++ const toolsUrl = 'https://tools.example.test';
++ const fetchImpl = async (url, o = {}) => {
++ seen.push({ url: String(url), method: o.method || 'GET', body: o.body || null });
++ const ok = (j) => ({ ok: true, status: 200, text: async () => JSON.stringify(j) });
++ if (String(url) === `${toolsUrl}/api/v1/tools`) return ok({ tools: [{ id: 'read', name: 'Read', summary: 'read a page', api: true, execution: 'sync', auth: { anonymous: true } }] });
++ if (String(url) === `${toolsUrl}/api/v1/tools/read/run`) return ok({ result: { title: 'ok' } });
++ return { ok: false, status: 404, text: async () => '{}' };
++ };
++ const config = { services: { toolsUrl, searchUrl: 'https://search.example.test' } };
++ const tools = createOpenVibeTools({ config, fetchImpl, log: { warn() {} } });
++ for (const u of INTERNAL) {
++ // A model that invents a tool id, or passes a URL where a tool id belongs: nothing is requested for it.
++ for (const bad of [u, `../../${u}`, `read/../${u}`, 'read?x=1', '']) await tools.call('run_tool', { tool: bad, input: { url: u } });
++ await tools.call('read_page', { url: u });
++ }
++ const urls = [...new Set(seen.map((s) => s.url))].sort();
++ assert.deepStrictEqual(urls, [`${toolsUrl}/api/v1/tools`, `${toolsUrl}/api/v1/tools/read/run`], 'Actor only called Tools\' catalog and its read tool');
++ const reads = seen.filter((s) => s.url.endsWith('/read/run'));
++ assert.strictEqual(reads.length, INTERNAL.length, 'one read_page per URL');
++ for (const [i, r] of reads.entries()) {
++ assert.strictEqual(r.method, 'POST');
++ assert.strictEqual(JSON.parse(r.body).input.url, INTERNAL[i], 'the URL travels as Tools input, for Tools\' own guard to judge');
++ }
++ });
++
++ await check('ratchet: every file that makes an outbound request itself is reviewed', () => {
++ const REVIEWED = {
++ 'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
++ 'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
++ 'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
++ 'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
++ };
++ const root = path.join(__dirname, '..');
++ const found = [];
++ const walk = (dir) => {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const f = path.join(dir, e.name);
++ if (e.isDirectory()) walk(f);
++ else if (e.name.endsWith('.js')) {
++ const src = fs.readFileSync(f, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/.*$/gm, '$1');
++ if (/(^|[^.\w])(fetch|fetchImpl)\(|\bhttps?\.(get|request)\(|new WebSocket\(|require\(['"](axios|got|node-fetch|undici)['"]\)/m.test(src)) found.push(path.relative(root, f));
++ }
++ }
++ };
++ walk(path.join(root, 'server'));
++ assert.ok(found.includes('server/auth/sso.js') && found.includes('server/agents/tools.js'), `the scan finds the known sites (${found.join(', ')})`);
++ assert.deepStrictEqual(found.filter((f) => !REVIEWED[f]).sort(), [], 'a new outbound request site: a caller-chosen URL goes to OpenVibe.Tools, never fetched here; then add the file with where it goes');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..a1c915c
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,59 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T16/J2): the entry point's graceful stop runs its close steps in order
++ * (the prune timer clears, the task engine stops and records running tasks as cancelled, the JWKS refresher stops, then the
++ * store closes: the order the manifest's shutdown.drains lists) and resolves exit 0; a second signal while stopping is a no-op.
++ */
++const assert = require('assert');
++const http = require('http');
++const { check, done } = require('./helpers/boot');
++const { createLifecycle } = require('../server/index');
++
++const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
++
++(async () => {
++ await check('the entry point stop clears the timers, stops the engine, stops the JWKS refresher, then closes the store, then exits 0', async () => {
++ const steps = [];
++ const ctx = {
++ engine: { stop: async () => { steps.push('engine.stop'); } },
++ keys: { client: { stop: () => { steps.push('keys.stop'); } } },
++ s: { close: async () => { steps.push('store.close'); } },
++ };
++ const server = http.createServer((req, res) => res.end('ok'));
++ await listen(server);
++
++ const exits = [];
++ const timer = setInterval(() => steps.push('tick'), 1_000_000);
++ const lifecycle = createLifecycle({ server, ctx, exit: (code) => exits.push(code), signals: false, timers: [timer] });
++ const code = await lifecycle.stop('SIGTERM');
++
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, ['engine.stop', 'keys.stop', 'store.close']);
++ assert.strictEqual(server.listening, false);
++ });
++
++ await check('a second signal while stopping is a no-op', async () => {
++ let stops = 0; let closes = 0; let exits = 0; let engineStops = 0;
++ const ctx = {
++ engine: { stop: async () => { engineStops += 1; } },
++ keys: { client: { stop: () => { stops += 1; } } },
++ s: { close: async () => { closes += 1; } },
++ };
++ const server = http.createServer((req, res) => res.end('ok'));
++ await listen(server);
++
++ const lifecycle = createLifecycle({ server, ctx, exit: () => { exits += 1; }, signals: false });
++ const first = lifecycle.stop('SIGTERM');
++ const second = lifecycle.stop('SIGINT');
++
++ assert.strictEqual(first, second);
++ assert.deepStrictEqual(await Promise.all([first, second]), [0, 0]);
++ assert.strictEqual(engineStops, 1);
++ assert.strictEqual(stops, 1);
++ assert.strictEqual(closes, 1);
++ assert.strictEqual(exits, 1);
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/tasks.test.js b/test/tasks.test.js
nnew file mode 100644
iindex 0000000..974e438
---- /dev/null
++++ b/test/tasks.test.js
@@@ -0,0 +1,257 @@
++'use strict';
++/**
++ * A task end to end through the API and the real adapters (stand-in providers, test/helpers/providers.js): it is
++ * classified, routed, run, checked and delivered as platform.task@1 with its cost and explanation; a failed agent or
++ * a failed check hands the task on; private mode never sends the text outside OpenVibe; budgets and the free tier are
++ * hard; cancel stops it; the live stream replays and ends; nobody sees another person's task.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const valid = (task) => { const v = contracts.validate('platform.task@1', task); assert.ok(v.valid, JSON.stringify(v.errors)); };
++const SAME = { 'sec-fetch-site': 'same-origin' };
++
++(async () => {
++ const t = await boot();
++ const kim = t.network.addUser('kim');
++ const lee = t.network.addUser('lee');
++ const create = (json, o = {}) => t.get('/api/v1/tasks', { as: o.as || kim, json, headers: { ...SAME, ...(o.headers || {}) } });
++
++ await check('a lookup task runs on the OpenVibe runtime through OpenVibe.Tools, is checked by another family, and is delivered', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'lookup';
++ const r = await create({ task: 'Who handles mail for openvibe.network?' });
++ assert.strictEqual(r.status, 201, r.text);
++ const created = r.json();
++ valid(created);
++ assert.strictEqual(created.state, 'queued');
++ assert.match(r.headers.get('location'), /^\/api\/v1\/tasks\/tsk_/);
++ await t.waitFor(created.id);
++ const task = (await t.get(`/api/v1/tasks/${created.id}`, { as: kim })).json();
++ valid(task);
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'openvibe-runtime');
++ assert.match(task.result.answer, /mailserver\.example\.com/, 'the tool result reached the answer');
++ assert.strictEqual(task.result.checked.by, 'openai');
++ assert.strictEqual(task.result.checked.cross_family, true);
++ assert.ok(task.cost.usd > 0 && task.cost.usd < 0.01, String(task.cost.usd));
++ assert.strictEqual(task.explanation[0].selected, 'openvibe-runtime');
++ assert.ok(task.explanation[0].candidates.some((c) => c.id === 'open-model' && /lacks task:lookup/.test(c.excluded_because)));
++ assert.ok(t.providers.requests.some((q) => q.path === '/tools/api/v1/tools/mx/run'), 'the runtime called Tools');
++ assert.strictEqual(task.requester.type, 'user');
++ assert.strictEqual('project_id' in task, false);
++ });
++
++ await check('a web task goes to OpenAI\'s agent, with its sources, checked by DeepSeek', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'web';
++ const id = (await create({ task: 'What changed in the latest Node.js release?' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'openai-agent');
++ assert.deepStrictEqual(task.result.sources, ['https://example.org/source']);
++ assert.strictEqual(task.result.checked.by, 'deepseek');
++ // 2000 in + 200 out at gpt-5-mini plus one search, plus the classifier and the checker on DeepSeek.
++ assert.ok(task.cost.usd > 0.0105 && task.cost.usd < 0.012, String(task.cost.usd));
++ });
++
++ await check('private mode: only the open model, and the task text never leaves OpenVibe', async () => {
++ t.providers.reset();
++ const secret = `private-${crypto.randomBytes(4).toString('hex')}`;
++ const id = (await create({ task: `Explain ${secret} in one line`, mode: 'private' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'open-model');
++ assert.strictEqual(task.result.checked.cross_family, false);
++ const outside = t.providers.requests.filter((q) => !q.path.startsWith('/local/') && JSON.stringify(q.body).includes(secret));
++ assert.deepStrictEqual(outside.map((q) => q.path), [], 'nothing outside the open model saw the task');
++ assert.strictEqual(task.cost.usd, 0);
++ });
++
++ await check('a private task the open model cannot do fails with why, before any model runs', async () => {
++ t.providers.reset();
++ const id = (await create({ task: 'What is the MX record of example.com?', mode: 'private' })).json().id;
++ const row = await t.waitFor(id);
++ assert.strictEqual(row.state, 'failed');
++ assert.strictEqual(row.error_code, 'actor.no_agent');
++ assert.match(row.error_detail, /Private mode/);
++ assert.strictEqual(t.providers.requests.filter((q) => /chat|responses/.test(q.path)).length, 0);
++ });
++
++ await check('a failing agent hands the task to the next one; both placements are in the explanation', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'research';
++ t.providers.state.runtime = 'fail';
++ const id = (await create({ task: 'Summarize what OpenVibe is', mode: 'cheapest' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ valid(task);
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'openai-agent');
++ assert.strictEqual(task.explanation.length, 2);
++ assert.strictEqual(task.explanation[0].selected, 'openvibe-runtime');
++ assert.ok(task.explanation[1].candidates.some((c) => c.id === 'openvibe-runtime' && /tried first: HTTP 500/.test(c.excluded_because)));
++ });
++
++ await check('an answer that fails its check is not delivered; the next agent gets the task', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'research';
++ // The OpenAI checker rejects the runtime's answer; DeepSeek accepts the OpenAI agent's.
++ t.providers.state.checkOk = (body) => !/Runtime answer/.test(JSON.stringify(body));
++ const id = (await create({ task: 'Read about OpenVibe and summarize it', mode: 'cheapest' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ assert.strictEqual(task.state, 'succeeded', JSON.stringify(task.error));
++ assert.strictEqual(task.result.agent, 'openai-agent');
++ assert.ok(!/Runtime answer/.test(task.result.answer));
++ const events = await t.get(`/api/v1/tasks/${id}/events`, { as: kim });
++ assert.match(events.text, /Check failed/);
++ assert.match(events.text, /Unchecked answer, not delivered/);
++ });
++
++ await check('when every agent fails its check the task fails, with no result', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'research';
++ t.providers.state.checkOk = false;
++ const id = (await create({ task: 'Read about OpenVibe and summarize it' })).json().id;
++ await t.waitFor(id);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ valid(task);
++ assert.strictEqual(task.state, 'failed');
++ assert.strictEqual(task.result, null);
++ assert.strictEqual(task.error.code, 'actor.agents.exhausted');
++ });
++
++ await check('a budget below the cheapest capable agent fails before anything is paid', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'web';
++ const id = (await create({ task: 'Latest news about Mars', budget: { per_task_usd: 0.001 } })).json().id;
++ const row = await t.waitFor(id);
++ assert.strictEqual(row.state, 'failed');
++ assert.strictEqual(row.error_code, 'actor.budget.exceeded');
++ assert.strictEqual(t.providers.requests.filter((q) => q.path === '/openai/responses').length, 0);
++ });
++
++ await check('a budget above the free tier is refused, never lowered; an unknown field or a webhook is refused', async () => {
++ const over = await create({ task: 'x', budget: { per_task_usd: 5 } });
++ assert.strictEqual(over.status, 422);
++ assert.strictEqual(over.json().code, 'actor.budget.over_tier');
++ const hooks = await create({ task: 'x', webhooks: [{ url: 'https://example.com', events: ['succeeded'] }] });
++ assert.strictEqual(hooks.status, 422);
++ assert.strictEqual(hooks.json().code, 'actor.task.invalid');
++ assert.strictEqual((await create({ task: ' ' })).status, 422);
++ });
++
++ await check('an idempotency key answers the first task for the same body and 409 for a different one', async () => {
++ t.providers.reset();
++ const a = await create({ task: 'Say hi', idempotency_key: 'hello-key-1' });
++ const b = await create({ task: 'Say hi', idempotency_key: 'hello-key-1' });
++ assert.strictEqual(a.status, 201);
++ assert.strictEqual(b.status, 200);
++ assert.strictEqual(a.json().id, b.json().id);
++ assert.strictEqual((await create({ task: 'Say bye', idempotency_key: 'hello-key-1' })).status, 409);
++ await t.waitFor(a.json().id);
++ });
++
++ await check('cancel stops a running task; it ends cancelled with the cancel recorded', async () => {
++ t.providers.reset();
++ t.providers.state.cls = 'lookup';
++ t.providers.state.runtime = 'hang';
++ const id = (await create({ task: 'MX of example.com please' })).json().id;
++ await t.waitFor(id, ['running']);
++ await new Promise((r) => setTimeout(r, 150));
++ const c = await t.get(`/api/v1/tasks/${id}/cancel`, { as: kim, method: 'POST', headers: SAME });
++ assert.ok([200, 202].includes(c.status), c.text);
++ await t.waitFor(id, ['cancelled']);
++ const task = (await t.get(`/api/v1/tasks/${id}`, { as: kim })).json();
++ valid(task);
++ assert.strictEqual(task.state, 'cancelled');
++ assert.strictEqual(task.cancel.requested_by.id, kim.subject);
++ });
++
++ await check('the live stream replays every event after Last-Event-ID and ends with `end`', async () => {
++ t.providers.reset();
++ const id = (await create({ task: 'Say hello' })).json().id;
++ await t.waitFor(id);
++ const all = await t.get(`/api/v1/tasks/${id}/events`, { as: kim });
++ assert.strictEqual(all.headers.get('content-type'), 'text/event-stream; charset=utf-8');
++ const events = [...all.text.matchAll(/^data: (.*)$/gm)].map((m) => JSON.parse(m[1]));
++ for (const e of events) assert.ok(contracts.validate('actor.task-event@1', e).valid, JSON.stringify(e));
++ assert.deepStrictEqual(events.map((e) => e.seq), events.map((_, i) => i + 1));
++ assert.strictEqual(events[events.length - 1].kind, 'end');
++ const after = await t.get(`/api/v1/tasks/${id}/events`, { as: kim, headers: { 'last-event-id': String(events.length - 1) } });
++ assert.strictEqual([...after.text.matchAll(/^id: /gm)].length, 1);
++ });
++
++ await check('nobody sees another person\'s task: 404, the same as a task that does not exist', async () => {
++ const id = (await create({ task: 'Mine only' })).json().id;
++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}`, { as: lee })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/events`, { as: lee })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/cancel`, { as: lee, method: 'POST', headers: SAME })).status, 404);
++ assert.ok(!(await t.get('/api/v1/tasks', { as: lee })).json().tasks.some((x) => x.id === id));
++ const mine = (await t.get('/api/v1/tasks', { as: kim })).json();
++ assert.ok(contracts.validate('actor.task-list-result@1', mine).valid);
++ assert.ok(mine.tasks.some((x) => x.id === id));
++ await t.waitFor(id);
++ });
++
++ await check('a signed-in write from another site is refused; no token at all is 401', async () => {
++ const cross = await t.get('/api/v1/tasks', { as: kim, json: { task: 'x' }, headers: { 'sec-fetch-site': 'cross-site' } });
++ assert.strictEqual(cross.status, 403);
++ assert.strictEqual(cross.json().code, 'request.cross_site');
++ assert.strictEqual((await t.get('/api/v1/tasks', { json: { task: 'x' } })).status, 401);
++ });
++
++ await check('an app token needs actor.task.create; with it the task belongs to its project', async () => {
++ const { serviceAuth } = contracts;
++ const now = Math.floor(Date.now() / 1000);
++ const project = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
++ const mint = (cap) => serviceAuth.signServiceToken({ iss: t.network.url, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', actor_type: 'app', aud: ['openvibe.actor'], cap, ns: [project], project_id: project, env: 'production', iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, t.network.privatePem);
++ const denied = await t.get('/api/v1/tasks', { bearer: mint(['actor.task.read']), json: { task: 'x' } });
++ assert.strictEqual(denied.status, 403);
++ t.providers.reset();
++ const ok = await t.get('/api/v1/tasks', { bearer: mint(['actor.task.create', 'actor.task.read']), json: { task: 'Say hello from an app' } });
++ assert.strictEqual(ok.status, 201, ok.text);
++ assert.strictEqual(ok.json().project_id, project);
++ assert.deepStrictEqual(ok.json().requester, { type: 'app', id: 'app_01JAB2C3D4E5F6G7H8J9K0MNPT' });
++ await t.waitFor(ok.json().id);
++ const read = await t.get(`/api/v1/tasks/${ok.json().id}`, { bearer: mint(['actor.task.read']) });
++ assert.strictEqual(read.json().state, 'succeeded');
++ });
++
++ await check('the free tier: tasks a day are counted and refused past the limit with Retry-After', async () => {
++ const u = t.network.addUser('busy');
++ const limit = t.config.allowance.tasksPerDay;
++ const store = require('../server/tasks/store');
++ await store.addSpend(t.ctx.s, `user:${u.subject}`, 0, { tasks: limit });
++ const r = await create({ task: 'one more' }, { as: u });
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.json().code, 'actor.allowance.exhausted');
++ assert.ok(Number(r.headers.get('retry-after')) > 0);
++ });
++
++ await check('the operator\'s daily ceiling over everyone stops new tasks', async () => {
++ const store = require('../server/tasks/store');
++ await store.addSpend(t.ctx.s, 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPZ', t.config.spendCapUsdPerDay);
++ const r = await create({ task: 'anything' }, { as: t.network.addUser('late') });
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.json().code, 'actor.capacity.spent');
++ });
++
++ await check('nothing the providers were sent or Actor stored contains a provider key', async () => {
++ const dump = await t.dbDump();
++ for (const k of ['ds-test-key', 'oa-test-key']) {
++ assert.ok(!dump.includes(k), `the database holds ${k}`);
++ assert.ok(!t.logs().includes(k), `the logs hold ${k}`);
++ }
++ assert.ok(t.providers.requests.filter((q) => q.path.startsWith('/deepseek')).every((q) => q.auth === 'Bearer ds-test-key'));
++ assert.ok(t.providers.requests.filter((q) => q.path.startsWith('/tools') || q.path.startsWith('/search')).every((q) => q.auth === null), 'OpenVibe services get no provider key');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });