openvibers/openvibe.services Commits
OpenVibe.Services: the kernel and the merged resource index over every authority's GET /api/v1/resources (plan T13 steps 3 and 6). (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,64 @@++# ═══════════════════════════════════════════════════════════════++# openvibe.services — Environment Configuration++# Copy to .env for development. In production this lives at /etc/openvibe/services.env (0600).++# Names only here; never commit a value.++# ═══════════════════════════════════════════════════════════════++++# Server++PORT=4930++HOST=127.0.0.1++NODE_ENV=development++BASE_URL=http://localhost:4930++TRUST_PROXY=1++++# ── Data: PostgreSQL (ADR-035) ──────────────────────────────++# Host's roles/add-service.sh writes both in production. Unset in development: an embedded PGlite++# database in data/pglite (one process). Tests need neither.++DATABASE_URL=++DATABASE_DIRECT_URL=++++# ── Identity: OpenVibe.Network ──────────────────────────────++OV_NETWORK_URL=https://openvibe.network++# The origin Services asks about project membership (GET /api/v1/projects/:project, with the caller's++# own user token) and reads the signing key from (GET /api/.well-known/jwks).++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++#OV_NETWORK_ISSUER=https://openvibe.network++# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.++#OV_NETWORK_PUBLIC_KEY=++# Audience Services answers for: incoming service tokens must carry it.++SERVICES_AUDIENCE=openvibe.services++# Services' own Network client: it mints one client-credentials token per authority audience++# (openvibe.network, openvibe.media, …), scoped to that authority's resource.read capability. Unset in++# development: the fan-out calls the authorities without a token and every authority that requires one++# is reported partial — the index degrades, it never stops (ADR-046 section 6).++OV_OAUTH_CLIENT_ID=services++OV_OAUTH_CLIENT_SECRET=++++# ── The authorities (plan T13 step 6) ───────────────────────++# An authority is a service whose pinned openvibe-contracts manifest lists <id>.resource.read as++# active; its loopback origin comes from that manifest. SERVICES_<ID>_URL overrides one for the++# operator (the id uppercased, '-' -> '_'):++#SERVICES_NETWORK_URL=http://127.0.0.1:4000++#SERVICES_MEDIA_URL=http://127.0.0.1:4100++#SERVICES_EVENTS_URL=http://127.0.0.1:4300++#SERVICES_CODES_URL=http://127.0.0.1:4900++#SERVICES_HOST_URL=http://127.0.0.1:4910++++# ── The resource index ──────────────────────────────────────++# The per-authority page size the fan-out asks for, how many authorities are read at once, and how long++# one authority may take before its rows are omitted and it is named in X-OpenVibe-Partial-Authorities.++SERVICES_INDEX_PAGE_SIZE=100++SERVICES_INDEX_CONCURRENCY=4++SERVICES_INDEX_TIMEOUT_MS=5000++# The caller-facing page size: default and ceiling.++SERVICES_INDEX_DEFAULT_LIMIT=100++SERVICES_INDEX_MAX_LIMIT=1000++# How long the membership question to Network may take before the person-facing path answers 503.++SERVICES_NETWORK_TIMEOUT_MS=5000++++# ── OpenVibe.Events ─────────────────────────────────────────++# Services declares no event type yet (plan T13 step 10), so the relay is idle. Set EVENTS_URL and the++# OAuth secret and it publishes the first registered services.* event the moment EVENT_TYPES has one.++EVENTS_URL=++#SERVICES_EVENTS_ENABLED=1++#EVENTS_RELAY_INTERVAL_MS=2000nnew file mode 100644@@@ -0,0 +1,21 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ # contracts-service: services — Services' own manifest is a placeholder until plan T13 step 4 registers++ # it (and services.resource.read, PROPOSED in server/auth.js until then), so the check reads this++ # repository's use of the pinned contracts; the contract ids it validates today: common.resource-summary@1.++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ contracts-service: services++ # 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 on the++ # containers through PgBouncer (SERVICES_TEST_STORE=pg).++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,15 @@++node_modules/++# A linked dependency tree is a symlink, which the pattern above (directory-only) does not match: the++# tooling that links one shared copy into a worktree must never be committable.++node_modules++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -0,0 +1,9 @@++# gitleaks configuration, used by the shared security workflow++# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every++# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.++#++# Services stores no credential of its own: the authority tokens are Network client-credentials tokens++# minted at call time from OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (.env.example carries names only)++# and never written to disk, a log line or a response body. Nothing here needs an allowlist yet.++[extend]++useDefault = truennew file mode 100644@@@ -0,0 +1,48 @@++# Changelog++++Notable changes to OpenVibe.Services. The service is unreleased; everything below is `0.1.0` in progress.++++## Unreleased++++### Added++++- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite in++ development, PostgreSQL through PgBouncer in production), `server/app.js`, `server/index.js`,++ `server/network.js` (the Network key provider, user-token verification, project membership),++ `server/auth.js` (`services.resource.read` in a `PROPOSED` set — plan T13 step 4 registers it),++ `server/observability.js` (`/api/health`, truthful `/api/ready`, loopback-only `/metrics`),++ `server/events/outbox.js` (idle: no `services.*` event type is registered yet),++ `migrations/0001_initial.sql` (the seven tables Services owns), `STATUS.json`, the nginx vhost and the++ systemd unit.++- The authority registry (plan T13 step 6, `server/authorities/index.js`): built at boot from the pinned++ openvibe-contracts service manifests — every non-placeholder service whose manifest lists an `active`++ `<id>.resource.read` capability, with its manifest `internalOrigin` (`SERVICES_<ID>_URL` overrides) and++ audience `openvibe.<id>`. With contracts v0.108.0: network, events, host, codes, media. Services itself is++ never an authority.++- One adapter per authority (`server/authorities/adapter.js`) and the per-authority token++ (`server/authorities/tokens.js`): `GET /api/v1/resources` and `GET /api/v1/resources/:ovrn` over the++ authority's loopback origin with a Network client-credentials token for its audience, scoped to its++ `resource.read` capability and cached (one mint per authority).++- The merged resource index (plan T13 step 6): `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=`++ fans out and merges every authority's pages with `openvibe-sdk/resources`' `createResourceIndex`, adds a++ deterministic (service, OVRN, id) order and an opaque keyset cursor, and filters the merged rows itself so a++ scoped read stays scoped. `GET /api/v1/resources/:ovrn` resolves the OVRN with contracts' one parser and asks++ the authority that owns it. `GET /api/v1/authorities` lists the registry (first-party).++- Partial pages: a slow, refusing or unreachable authority — or a Network that cannot mint its token — never++ fails the page; its rows are omitted and it is named in `X-OpenVibe-Partial-Authorities`++ (ADR-046 section 6). `common.resource-list-result@1` has no field for it; the contract gap is recorded in the++ README.++- The person-facing path: a Network user token reads only projects the person owns or belongs to, one at a time,++ with membership answered by Network's developer projects API using the caller's own token (non-members get the++ same 404 Network gives). Never an unscoped person, and never through an app, mod or agent token — the service++ path is `svc:` principals holding `services.resource.read` only.++++### Notes++++- Services aggregates authorities and owns no other service's rows (ADR-048): it never opens, writes or migrates++ another service's database, and every control operation (plan T13 step 10) will be a call to the owning++ authority.++- `services.resource.read` is not registered in openvibe-contracts yet; it is enforced from the `PROPOSED` set in++ `server/auth.js` with contracts' own grant rules, so step 4 registers exactly what this service enforces.++- The repository is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_services` database,++ `/etc/openvibe/services.env` and `openvibe.services` DNS (see STATUS.json).nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,149 @@++# OpenVibe.Services++++The resource control plane of the network (openvibe.services): one merged index over every authority, and — as plan++T13 lands — the console and the control operations on top of it. Design: OpenVibe.Contracts++docs/adr/ADR-048-services-control-plane.md; plan track T13.++++## Purpose++++Every service that owns resources answers `GET /api/v1/resources` with a `common.resource-list-result@1` page of++`common.resource-summary@1` (`ADR-048`; the resource-index sweep adds that route to each service in turn). Services is++the one place that fans out over those authorities, merges their pages, and answers a single index — so a console,++an operator or a developer sees every resource of every project they may see without any service learning another++service's schema.++++**Services aggregates authorities; it never replaces one.** It reads each authority's resource index and keeps at++most a read model it can rebuild; it never opens, writes or migrates another service's database, and every control++operation (plan T13 step 10) will be a call to the authority that owns the resource. An authority, or Network, being++down degrades the index into a partial page — it never stops it (ADR-046 §6).++++This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out index. The read model and ingestion++(step 7), the console (step 9), the control operations (step 10), recipes (step 11) and the usage/cost surfaces++(step 12) are not built yet; this service declares them nowhere.++++## Routes++++| Method and path | Capability | Request → answer |++|---------------------------------------------------|---------------------------|------------------|++| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in `X-OpenVibe-Partial-Authorities` |++| `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) |++| `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) |++| `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest |++| `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |++++`services.resource.read` is **PROPOSED**: it is not registered in the pinned openvibe-contracts yet (plan T13 step 4++does that after this repository exists). It lives in `server/auth.js` in a `PROPOSED` set and is enforced with++contracts' own grant rules (`capabilities.grants`, wildcards included), so what step 4 registers is exactly what this++service enforces. `services.resource.control` arrives with step 10.++++Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |++resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,++`404 resources.not_found`, `502 resources.authority_unavailable | resources.authority_error |++resources.authority_bad_response`, `503 network.unavailable | identity.unavailable`.++++## Who may read the index++++- **A Network service token** for audience `openvibe.services`, `svc:` principal, holding `services.resource.read`++ (first-party): the whole index, over every authority and every project; `?project=` only narrows it. An app, mod++ or agent token is refused even if it somehow holds the grant — a person reads with their own user token, never++ through a developer app's credential.++- **A person's Network user token**: only projects that person owns or belongs to, and only ONE at a time —++ `?project=prj_…` (or the OVRN's own project) is required, and Network answers the membership question++ (`GET /api/v1/projects/:project`, the developer projects API) with the caller's own token. A project the person is++ not a member of is `404 resources.project_not_found`, the same non-disclosure Network itself uses — never a `403`++ that would confirm the project exists. A person is never accepted unscoped.++++`GET /api/v1/authorities` is first-party operational data (loopback origins) and answers only a service token.++++## The merged index++++The fan-out, each authority's opaque-cursor walk and the merge are `openvibe-sdk/resources`' `createResourceIndex`++(SDK v0.34.0, `docs/service.md` §"The resource index and the control plane") — Services does not reimplement any of++it. Services adds:++++- **the authority set** — one adapter per authority (`server/authorities/adapter.js`), each calling that authority's++ loopback origin with its own Network client-credentials token (`server/authorities/tokens.js`: one cached token per++ audience, scoped to that authority's `resource.read` capability, refreshed 60 s before expiry);++- **a deterministic total order** over the merged resources (service, OVRN, id) and an opaque keyset cursor into it,++ so a page walk repeats nothing and skips nothing while the underlying data is stable;++- **local filters** for `project`, `kind` and `service` on top of the authorities' own answers: a scoped read (a++ person's, above all) never shows a row outside its scope even if an authority ignores a query parameter;++- **the partial answer**. `common.resource-list-result@1` has no field for the authorities that could not be read++ (its only properties are `resources` and `next_cursor`, `additionalProperties: false`), so a page whose fan-out was++ incomplete omits those rows and names the authorities in the `X-OpenVibe-Partial-Authorities` response header.++ **Contract gap (for a later Contracts release):** a `common.resource-list-result@1` field for the partial++ authorities (e.g. `stale: [{ service, status, code }]`, or a `partial: true` and a separate list) would let a++ client read the degradation from the body instead of a header.++++An authority is never required to answer for the index to answer: a timeout (default 5 s per authority), a refusal,++a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page++intact.++++## The authority registry++++`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:++every service whose manifest lists `<id>.resource.read` **and** whose capability manifest says `status: active` is an++authority. With contracts v0.108.0 that is **network, events, host, codes and media**; the moment a service's++manifest turns that on, it is in the registry on the next boot, and nothing here is hand-maintained. Services itself++is never an authority. Each authority's loopback origin comes from its manifest (`internalOrigin`);++`SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is `openvibe.<id>`.++++There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and++`*.resource.*` ingestion are step 7.++++## Owns++++- The authority registry (derived from the pinned Contracts manifests, not stored) and the merged resource index.++- Its own database (ADR-035): `migrations/0001_initial.sql` — `services_authorities`, `services_resources`,++ `services_catalog_entries`, `services_recipes`, `services_recipe_runs`, `services_control_log`,++ `services_events_outbox`. All of it is the console's own state or a read model it can rebuild; no table is another++ service's.++++## Does not own++++- Any other service's rows, bytes, grants, events or decisions: every resource belongs to its authority, and every++ control operation (step 10) will be a `common.resource-control-request@1` to that authority.++- Identity, projects, apps, keys and nodes: OpenVibe.Network. Services asks Network's developer API with the caller's++ own token and never caches a membership decision beyond the request.++- The persons and tokens themselves: Services verifies the tokens it is the audience of (`openvibe.services`) and++ passes a user token through to Network only for the membership question.++++## Depends on++++- PostgreSQL 18 (ADR-035): `migrations/` applies at boot on the owner role; the service then serves on the pooled++ runtime role. Unset in development: PGlite in `data/pglite`.++- OpenVibe.Network: the RS256 signing key (`GET /api/.well-known/jwks`), Services' OAuth client (`services`) for the++ per-authority tokens, and the developer projects API for membership.++- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at the++ current pin. Every one of them is optional to serve, by design.++- `openvibe-contracts` v0.108.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0 (package.json).++++## Tests++++```bash++npm test # every file on PGlite, one process at a time++npm run test:pg # the same on PostgreSQL + PgBouncer containers++node test/index.test.js # one file++```++++No test needs the network or a running service: `test/helpers/stubs.js` is an in-process OpenVibe.Network (JWKS,++`/oauth/token` with every mint recorded, project membership) and in-process authorities whose pages, timeouts and++refusals the tests drive. The index tests cover the merged page walk with a stable cursor, the filters, the++person-scoped path (including an authority that ignores `?project=`), a hanging and a refusing authority, the OVRN++route, the malformed-name and unknown-service refusals, and one token mint per authority.++++## Deployment++++`deploy/systemd/openvibe-services.service` (WorkingDirectory `/opt/openvibe.services`, EnvironmentFile++`/etc/openvibe/services.env`, PORT 4930) and `deploy/nginx/openvibe.services.conf`. Not deployed yet: no DNS, no++`ovhost` entry, no `ov_services` database and no `/etc/openvibe/services.env`. The first deploy needs Services'++Network client (`OV_OAUTH_CLIENT_ID=services` with its secret) granted every authority's `<id>.resource.read` scope —++otherwise every authority refuses and the index is partial (correctly, and visibly).++++## Versions++++<!-- versions:start -->++- openvibe-contracts: v0.108.0++- openvibe-sdk: v0.34.0++- openvibe-shared: v2.11.0++<!-- versions:end -->nnew file mode 100644@@@ -0,0 +1,48 @@++# 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, projects, resources or data.++++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.services, openvibe.network, openvibe.live, openvibe.media, openvibe.run,++openvibe.bot 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.++++## This service++++OpenVibe.Services aggregates every authority's resource index and (from plan T13 step 10) routes control operations++to them, so a defect here can expose another project's resources or act on them. What Services guarantees, and what++a report should assume:++++- **no other service's data is written.** Services never opens, writes or migrates another service's database. Its++ own rows are migrations/0001_initial.sql — the authority registry, a rebuildable read model, recipes and the++ control log.++- **a person reads only their own projects.** A Network user token is accepted only for a project the person owns or++ belongs to, verified with the caller's own token against Network's developer projects API++ (`GET /api/v1/projects/:project`); a project they are not a member of is `404`, never a `403` that confirms it++ exists. A person is never accepted unscoped, and the fan-out passes only that one project to the authorities.++- **a scoped read stays scoped.** Services filters `project` (and `kind`, `service`) on the merged rows itself, so an++ authority that ignores a query parameter cannot leak another project's resource into a person's page.++- **a service token is judged on its own claims.** Audience `openvibe.services`, `svc:` principal, capability++ `services.resource.read` (PROPOSED until plan T13 step 4 registers it — enforced with contracts' own grant rules).++ A bad token is refused, never downgraded to anonymous, and an app, mod or agent token is refused even with the++ grant: a person reads with their own user token, never through a developer app's credential.++- **no secret is stored, logged or served.** The per-authority client-credentials tokens are minted at call time,++ cached in memory with a 60 s refresh skew and never written to disk, a log line or a response body. The only route++ that shows operational detail, `GET /api/v1/authorities`, is first-party (a service token) and carries no secret.++- **a failure degrades, it never stops.** An authority or Network being down yields a partial page named in++ `X-OpenVibe-Partial-Authorities`; the rest of the index keeps serving (ADR-046 section 6).++- nothing starts at module load (the key refresher and the outbox relay start in `server/index.js` `main()`), and++ `/metrics` answers only a direct loopback caller that no proxy relayed.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,41 @@++{++ "repository": "OpenVibers/OpenVibe.Services",++ "stage": "alpha",++ "code": true,++ "wave": "W13",++ "plan": "OpenVibe Updated Plan Based On Codebase (plan T13, ADR-048)",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",++ "port": 4930,++ "domain": "openvibe.services",++ "contracts": "openvibe-contracts v0.108.0",++ "sdk": "openvibe-sdk v0.34.0",++ "shared": "openvibe-shared v2.11.0",++ "packages": [++ "openvibe-contracts v0.108.0",++ "openvibe-sdk v0.34.0",++ "openvibe-shared v2.11.0"++ ],++ "capabilities": [++ "services.resource.read"++ ],++ "capabilitiesRegistered": false,++ "capabilitiesNote": "PROPOSED (server/auth.js): services.resource.read is not in the pinned openvibe-contracts yet; plan T13 step 4 registers it (and the later services.resource.control, services.recipe.run, services.usage.read, services.cost.read) after this repository exists. Services enforces exactly the id it will register.",++ "events": [],++ "deployed": false,++ "deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /api/, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.",++ "features": [++ "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.108.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",++ "one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",++ "the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",++ "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the X-OpenVibe-Partial-Authorities header — common.resource-list-result@1 has no field for it (ADR-046 section 6; the contract gap is recorded in the README)",++ "GET /api/v1/resources/:ovrn routes the name with openvibe-contracts' one parser to the authority that owns it and answers its common.resource-summary@1; a malformed name or an unknown service is 400, the authority's 404 is 404, anything else is 502",++ "GET /api/v1/authorities: the registry as it stands (id, origin, audience, capability, manifest version) — Services-local, no contract yet",++ "who may read: a first-party svc: Network service token holding services.resource.read, or a person's user token scoped to ONE project they own or belong to, verified with the caller's own token against Network's developer projects API (GET /api/v1/projects/:project; non-members are 404, existence is not disclosed). Never an unscoped person, and never an app, mod or agent token even with the grant",++ "the kernel: config/db (PGlite in development, PostgreSQL through PgBouncer in production; migrations applied at boot), truthful /api/ready (db and network_jwks required; a down authority is a partial page, not a red readiness), loopback-only /metrics, /api/health, /release.json, RFC 9457 problem+json everywhere, and the openvibe-sdk outbox (idle: no services.* event type is registered yet, plan T13 step 10)",++ "migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"++ ],++ "notYet": [++ "plan T13 step 4 (OpenVibe.Contracts registers the services.* capabilities, the service manifest leaves placeholder), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"++ ],++ "updated": "2026-10-07"++}nnew file mode 100644@@@ -0,0 +1,114 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.services — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.services.conf — keep them in sync and reload nginx++## after changing it. The Node app (port 4930, systemd unit openvibe-services) serves:++##++## /api/v1/resources… the resource index (services.resource.read); every request needs a++## Network service token for audience openvibe.services, or a person's++## user token scoped to one project they own or belong to++## /api/health, /api/ready, /release.json++## /metrics Prometheus text, answered only to direct loopback callers (never proxied)++## /internal/ host-local service endpoints, refused here++##++## The console (plan T13 step 9) is not served yet: today every other path is a 404 problem+json.++##++## The client address reaches the app as $remote_addr only, same in X-Forwarded-For and CF-Connecting-IP:++## the header is set here, never appended to or passed through from the client, so the app's own view of++## who is calling cannot be spoofed.++##++## No proxy_cache: the index answers no-store (a partial page must never be cached as if it were whole).++## Never log request bodies or the Authorization header: a service token and a person's token both ride++## in one, and the index passes the caller's token to no one but Network (project membership).++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=ovservices_api:10m rate=20r/s;++++# ── HTTP → HTTPS ────────────────────────────────────────────++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.services www.openvibe.services;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.services$request_uri; }++}++++# ── www → apex ──────────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.services;++++ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.services$request_uri;++}++++# ── The service ─────────────────────────────────────────────++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.services;++++ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.services.access.log;++ error_log /var/log/nginx/openvibe.services.error.log;++++ # The API answers query strings and small JSON bodies only (server/app.js, 64 kB).++ client_max_body_size 1m;++++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin 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 application/json application/problem+json text/plain;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr; # the client address, from $remote_addr only++ proxy_set_header X-Forwarded-For $remote_addr; # never appended to, never passed through++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Health, readiness, the release manifest ─────────────++ location = /api/health { proxy_pass http://127.0.0.1:4930; }++ location = /api/ready { proxy_pass http://127.0.0.1:4930; }++ location = /release.json { proxy_pass http://127.0.0.1:4930; }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4930/metrics) ──++ location = /metrics { return 404; }++++ # ── Host-local service endpoints ────────────────────────++ location /internal/ { return 404; }++++ # ── The resource index ──────────────────────────────────++ # The fan-out is slow by nature (it reads every authority): give it a generous read timeout while++ # the app's own per-authority timeout keeps one slow authority from holding the page (it lands in++ # X-OpenVibe-Partial-Authorities instead).++ location /api/ {++ limit_req zone=ovservices_api burst=40 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:4930;++ proxy_read_timeout 30s;++ }++++ # ── Nothing else is served yet (the console is plan T13 step 9) ──++ location / { return 404; }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,33 @@++[Unit]++Description=OpenVibe.Services — the resource control plane (authority registry, resource index; plan T13, ADR-048)++# Network (sign-in, JWKS, OAuth, projects) should be up first, and Services' own PostgreSQL (pooled++# runtime role plus the direct owner role for migrations) is required to serve. The authorities are++# NOT required: an authority being down degrades the index into a partial page, it never stops Services++# (ADR-046 section 6), so nothing here Wants their units.++After=network.target postgresql.service pgbouncer.service++Wants=openvibe-network.service postgresql.service pgbouncer.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.services++EnvironmentFile=/etc/openvibe/services.env++Environment=NODE_ENV=production++Environment=PORT=4930++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.services/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# Services reads no file from under its own tree at request time (the migration directory is read at++# boot) and holds no credential of its own: the authority tokens are minted from OV_OAUTH_CLIENT_SECRET++# in /etc/openvibe/services.env (0600) and are never written anywhere.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,100 @@++-- phase: expand++-- OpenVibe.Services' own rows (ADR-048, plan T13). Services aggregates authorities and owns no other++-- service's data: every table here is the console's own state or a read model it can rebuild from the++-- authorities' resource indexes and events. No table here is another service's, and Services never++-- opens, writes or migrates another service's database.++--++-- Nothing in this migration is another authority's truth:++-- services_authorities the authority registry's own record (id, origin, trust, last seen); the++-- registry Services serves is built at boot from the released++-- openvibe-contracts service manifests (server/authorities/index.js) and++-- uses this table only to remember what it has seen (plan T13 step 7+).++-- services_resources the rebuildable read model of resources other services own (step 7).++-- services_catalog_entries the product catalog move out of OpenVibe.Sites (step 14).++-- services_recipes, services_recipe_runs onboarding recipes (step 11).++-- services_control_log one row per control operation Services routed to an authority (step 10);++-- the authority's own answer is the truth, this is the console's record.++-- services_events_outbox the openvibe-sdk transactional outbox; Services declares no event type++-- yet (step 10), so the relay is idle and rows would wait here.++CREATE TABLE IF NOT EXISTS services_authorities (++ id text PRIMARY KEY, -- contracts service id: network, media, events, host, codes, …++ name text NOT NULL,++ public_origin text,++ internal_origin text NOT NULL,++ manifest_version text NOT NULL,++ contracts_range text,++ trust text NOT NULL DEFAULT 'first-party' CHECK (trust IN ('first-party','partner','community','external')),++ status text NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown','up','degraded','down','draining')),++ last_seen_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE IF NOT EXISTS services_resources (++ ovrn text PRIMARY KEY, -- common.resource-name@1++ service text NOT NULL REFERENCES services_authorities(id),++ project_id text NOT NULL,++ kind text NOT NULL, -- <service>.<type>++ resource_id text NOT NULL,++ name text,++ state text NOT NULL,++ owner_subject text,++ usage jsonb NOT NULL DEFAULT '{}'::jsonb,++ links jsonb NOT NULL DEFAULT '{}'::jsonb,++ authority_updated_at timestamptz,++ seen_at timestamptz NOT NULL DEFAULT now()++);++CREATE INDEX IF NOT EXISTS services_resources_project_idx ON services_resources (project_id, kind, ovrn);++CREATE INDEX IF NOT EXISTS services_resources_service_idx ON services_resources (service, seen_at);++CREATE TABLE IF NOT EXISTS services_catalog_entries (++ id text PRIMARY KEY, -- product domain slug++ domain text NOT NULL,++ status text NOT NULL CHECK (status IN ('live','planned','parked')),++ product jsonb NOT NULL, -- registry.product@1++ updated_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE IF NOT EXISTS services_recipes (++ id text PRIMARY KEY,++ name text NOT NULL,++ version integer NOT NULL DEFAULT 1,++ description text NOT NULL,++ steps jsonb NOT NULL, -- [{ id, authority, action, params, needs[] }]++ created_at timestamptz NOT NULL DEFAULT now()++);++CREATE TABLE IF NOT EXISTS services_recipe_runs (++ id text PRIMARY KEY, -- rcp_<ULID>++ recipe_id text NOT NULL REFERENCES services_recipes(id),++ project_id text NOT NULL,++ subject text NOT NULL,++ state text NOT NULL CHECK (state IN ('running','done','failed','cancelled')),++ steps jsonb NOT NULL DEFAULT '[]'::jsonb,++ started_at timestamptz NOT NULL DEFAULT now(),++ finished_at timestamptz++);++CREATE TABLE IF NOT EXISTS services_control_log (++ id text PRIMARY KEY, -- ctl_<ULID>++ ovrn text,++ action text NOT NULL,++ actor_subject text NOT NULL,++ project_id text,++ idempotency_key text NOT NULL,++ request jsonb NOT NULL,++ response jsonb,++ status text NOT NULL CHECK (status IN ('done','pending','refused','failed')),++ at timestamptz NOT NULL DEFAULT now(),++ UNIQUE (idempotency_key)++);++-- The openvibe-sdk outbox shape (outboxSchema('services_events_outbox')): written inside the change's++-- own transaction when Services has an event to emit, relayed to OpenVibe.Events.++CREATE TABLE IF NOT EXISTS services_events_outbox (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ event_id text NOT NULL UNIQUE,++ envelope jsonb NOT NULL,++ traceparent text,++ created_at bigint NOT NULL,++ attempts integer NOT NULL DEFAULT 0,++ next_attempt_at bigint NOT NULL DEFAULT 0,++ sent_at bigint,++ seq bigint,++ rejected_at bigint,++ last_error text++);nnew file mode 100644@@@ -0,0 +1,1284 @@++{++ "name": "openvibe-services",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-services",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.2"++ },++ "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/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/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-signature": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",++ "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/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/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.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.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.108.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",++ "integrity": "sha512-T07n7B482/XIdSD3ikehPGHFUTWsyGaeLCF2SerWsKD1V09s1RYs7TD9+Y2z7vdxYjSYhQPMHr1r2xP3F9xIbA==",++ "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.34.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",++ "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.11.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",++ "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/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/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,39 @@++{++ "name": "openvibe-services",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Services — the resource control plane (plan T13, ADR-048): the authority registry, the resource index fanned out over every authority, and (later) the console and control operations.",++ "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": "SERVICES_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe",++ "services",++ "resources",++ "control-plane"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "repository": "github:OpenVibers/OpenVibe.Services",++ "dependencies": {++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "jsonwebtoken": "^9.0.2"++ },++ "engines": {++ "node": ">=22.0.0"++ }++}nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++++/**++ * /api/v1 — the Services resource index (ADR-048, plan T13 step 6). The route → capability map:++ *++ * | Method & path | Capability | Request → answer |++ * |----------------------------|----------------------------|-----------------------------------------------------------|++ * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; the authorities a page could not read are named in X-OpenVibe-Partial-Authorities) |++ * | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |++ * | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |++ *++ * services.resource.read is PROPOSED (server/auth.js): it is not in the pinned openvibe-contracts yet++ * (plan T13 step 4 registers it), and Services enforces exactly the id it will register.++ *++ * Who may call: a Network service token holding services.resource.read (any project, ?project= only++ * narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their++ * own token against Network (server/auth.js authorizeProject). Never an unscoped person.++ *++ * Errors are RFC 9457 problem+json: 400 resources.bad_query / resources.bad_name /++ * resources.unknown_service / resources.project_required, 401 token.missing, 403 capability.denied,++ * 404 resources.not_found, 502 resources.authority_unavailable / resources.authority_error, 503++ * network.unavailable.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { ServiceError } = require('../util');++++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const SERVICE_RE = /^[a-z][a-z0-9-]{0,31}$/;++const KIND_RE = /^[a-z][a-z0-9-]{0,31}\.[a-z][a-z0-9-]{0,39}$/;++++/** The list query, or a 400. An absent filter is null; a malformed one is refused, never ignored. */++function listQuery(query, config) {++ const out = { project: null, kind: null, service: null, cursor: null, limit: config.index.defaultLimit };++ if (query.project != null && query.project !== '') {++ if (typeof query.project !== 'string' || !PROJECT_RE.test(query.project)) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');++ out.project = query.project;++ }++ if (query.kind != null && query.kind !== '') {++ if (typeof query.kind !== 'string' || !KIND_RE.test(query.kind)) throw new ServiceError(400, 'resources.bad_query', 'kind must be <service>.<type>');++ out.kind = query.kind;++ }++ if (query.service != null && query.service !== '') {++ if (typeof query.service !== 'string' || !SERVICE_RE.test(query.service)) throw new ServiceError(400, 'resources.bad_query', 'service must be a service id');++ out.service = query.service;++ }++ if (query.cursor != null && query.cursor !== '') {++ // The shape is checked here; whether it is a cursor THIS index issued is the index's own check++ // (server/resources.js decodeCursor), so there is one cursor format and one place that knows it.++ if (typeof query.cursor !== 'string' || query.cursor.length > 2048) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');++ out.cursor = query.cursor;++ }++ if (query.limit != null && query.limit !== '') {++ if (typeof query.limit !== 'string' || !/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > config.index.maxLimit) {++ throw new ServiceError(400, 'resources.bad_query', `limit must be an integer 1-${config.index.maxLimit}`);++ }++ out.limit = Number(query.limit);++ }++ return out;++}++++function v1Router({ config, apiAuth, index, authorities }) {++ const r = express.Router();++++ // One merged page over every authority (or the one ?service= names). A failing authority's rows are++ // omitted and named in the partial header; the rest of the page is served.++ r.get('/resources', async (req, res) => {++ apiAuth.requireRead(req);++ const filter = listQuery(req.query, config);++ const scope = await apiAuth.authorizeProject(req, filter.project);++ const page = await index.list({ ...filter, project: scope.project });++ if (page.partial.length) res.set('X-OpenVibe-Partial-Authorities', page.partial.join(', '));++ res.json({ resources: page.resources, next_cursor: page.next_cursor });++ });++++ // One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),++ // the owning service resolved (400), and the caller authorized for its project — a person must be a++ // member of that project; a service token needs services.resource.read. Nothing is asked of the++ // authority before the caller is authorized.++ r.get('/resources/:ovrn', async (req, res) => {++ apiAuth.requireRead(req);++ const ovrn = String(req.params.ovrn);++ const { project_id: project } = index.resolve(ovrn);++ await apiAuth.authorizeProject(req, project);++ res.json(await index.get(ovrn));++ });++++ // The authorities Services reads, and where they listen. First-party operational data (loopback++ // origins), so a service token with services.resource.read — not the person-facing path. A++ // Services-local answer: no contract names it yet.++ r.get('/authorities', async (req, res) => {++ apiAuth.requireServiceRead(req);++ res.json({++ authorities: authorities.list().map((a) => ({++ id: a.id, name: a.name, origin: a.internalOrigin, public_origin: a.publicOrigin,++ audience: a.audience, capability: a.capability, manifest_version: a.manifestVersion,++ contracts_range: a.contractsRange, status: a.status,++ })),++ });++ });++++ r.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { ctx: req.ov }));++ return r;++}++++module.exports = { v1Router, listQuery };nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * OpenVibe.Services — the resource control plane (ADR-048, plan T13). Express app factory;++ * server/index.js listens, tests build their own instance. Everything is injectable and nothing starts++ * at module load (the key refresher and the outbox relay start in server/index.js main()).++ *++ * GET /api/health, /api/ready, /release.json liveness, truthful readiness, the release manifest++ * GET /metrics direct loopback callers only (never proxied)++ * /api/v1/* the resource index (api/v1.js: one capability per route)++ *++ * createApp({ config, db, keys, userAuth, membership, authorities, tokens, index, outbox, registry,++ * fetchImpl, now, log })++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const rateLimit = require('express-rate-limit');++const { http } = require('openvibe-contracts');++const { loadConfig } = require('./config');++const { openDb } = require('./db');++const { createKeyProvider, createUserAuth, createMembership } = require('./network');++const { createAuthorities } = require('./authorities');++const { createAuthorityTokens } = require('./authorities/tokens');++const { createResourceIndexService } = require('./resources');++const { createServicesOutbox } = require('./events/outbox');++const { createApiAuth } = require('./auth');++const { v1Router } = require('./api/v1');++const { createServicesReadiness, registerServicesGauges } = require('./observability');++const { ServiceError } = require('./util');++const { createRegistry, instrument } = require('openvibe-shared/metrics');++++const VERSION = require('../package.json').version;++// The API takes query strings and (later) small JSON bodies; nothing here uploads.++const JSON_LIMIT = '64kb';++++function createApp(opts = {}) {++ const config = opts.config || loadConfig();++ const db = opts.db || openDb(config);++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const log = opts.log || console;++ const now = opts.now || (() => Date.now());++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });++ const userAuth = createUserAuth(config, keys);++ const membership = opts.membership || createMembership(config, { fetchImpl });++ const authorities = opts.authorities || createAuthorities(config, { log });++ const tokens = opts.tokens || createAuthorityTokens(config, { authorities, fetchImpl });++ const index = opts.index || createResourceIndexService({ config, authorities, tokens, fetchImpl, log });++ const outbox = opts.outbox || createServicesOutbox({ db, config, fetchImpl, now, log });++ const apiAuth = createApiAuth({ config, keys, userAuth, membership });++ const release = require('openvibe-shared/release').createRelease({ service: 'services', root: path.join(__dirname, '..') });++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ // Services answers JSON only for now (the console, plan T13 step 9, will widen this): helmet's++ // defaults carry what matters and there is no page yet whose CSP would need widening.++ app.use(helmet({ contentSecurityPolicy: false }));++++ const registry = opts.registry || createRegistry();++ registerServicesGauges(registry, { authorities });++ const metrics = instrument(app, { service: 'services', release: release.release, registry });++ app.use(http.middleware());++++ // Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are++ // /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.++ app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({++ ok: true, service: 'services', version: VERSION, authorities: authorities.size(), events,++ }), next));++ const readiness = createServicesReadiness({ db, keys, authorities, release: release.release });++ app.get('/api/ready', readiness.handler);++ release.mount(app, { registry: metrics.registry });++++ app.use('/api/v1',++ // A per-address cap: the fan-out reaches every authority, so a caller loop here is every++ // authority's load too; a refusal is an RFC 9457 problem like every other answer, never text.++ rateLimit({++ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false,++ handler: (req, res) => http.sendProblem(res, 429, 'services.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),++ }),++ express.json({ limit: JSON_LIMIT }),++ (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },++ apiAuth.middleware,++ v1Router({ config, apiAuth, index, authorities }));++++ // No page is served yet (the console is plan T13 step 9): every unknown route, page or API, is the++ // same problem+json.++ app.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { ctx: req.ov }));++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, next) => {++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });++ if (err instanceof ServiceError && !res.headersSent) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });++ log.error('[Services] unhandled error:', err);++ if (res.headersSent) return undefined;++ return http.sendProblem(res, 500, 'services.internal', { ctx: req.ov });++ });++++ Object.assign(app.locals, { config, db, keys, userAuth, membership, authorities, tokens, index, outbox, apiAuth, metrics, registry });++ return app;++}++++module.exports = { createApp, VERSION, JSON_LIMIT };nnew file mode 100644@@@ -0,0 +1,156 @@++'use strict';++++/**++ * Who is calling /api/v1 — resolved into req.principal:++ *++ * { kind: 'service', sub: 'svc:live', cap: [...], claims, project_id, jti } a Network client-credentials++ * token for audience openvibe.services; the index routes++ * check services.resource.read (server/api/v1.js).++ * { kind: 'user', subject: 'usr_…', username, token } a person's Network user token (SSO). A person++ * reads ONLY projects they own or belong to, verified with++ * their own token against Network (server/network.js++ * projectMember), and only one project at a time.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.++ *++ * Capabilities: Services' capability ids are not registered in openvibe-contracts yet (plan T13 step 4++ * registers them after this repository exists). They live here in PROPOSED, and PROPOSED ids are decided++ * with contracts' own grant rules (wildcards included) so that what step 4 registers is exactly what++ * Services enforces. Once an id is registered, the contracts manifest decides, unchanged.++ */++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');++const { ServiceError } = require('./util');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const ANON = Object.freeze({ kind: 'anonymous' });++++/**++ * The capabilities Services enforces (PROPOSED: no manifest in the pinned openvibe-contracts yet).++ * services.resource.read is the whole read side of the resource index (list, one, the authority list).++ * services.resource.control arrives with plan T13 step 10.++ */++const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });++const PROPOSED = Object.freeze(new Set(Object.values(CAPS)));++++/** One capability against a token's claims: contracts' manifest where there is one, the PROPOSED grant rules where not. */++function capabilityDecision(claims, id) {++ if (!PROPOSED.has(id)) return capabilities.check(claims, id);++ const allowed = capabilities.grants(claims && claims.cap, id);++ return allowed++ ? { allowed: true, code: null, reason: null }++ : { allowed: false, code: 'capability.denied', reason: `${id} not granted` };++}++++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; }++}++++/** Verify a service token (audience openvibe.services). Returns { ok, claims } or { ok:false, code, reason }. */++function verifyService(token, { publicKey, issuer, audience }) {++ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };++ const payload = decodePayload(token);++ if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });++ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };++ return { ok: true, claims: r.claims };++}++++/** A Network user token's claims → the user principal (null when it names no canonical subject). */++function userPrincipal(claims, token) {++ if (!claims) return null;++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;++ if (!subject) return null;++ return {++ kind: 'user', subject, token, username: claims.username || null,++ name: claims.display_name || claims.username || null, role: claims.role || 'user', cap: [], claims,++ };++}++++/** The project a principal names, or null. */++const projectOf = (principal) => {++ const id = principal && principal.claims && principal.claims.project_id;++ return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;++};++++function createApiAuth({ config, keys, userAuth, membership }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return { principal: ANON };++ const token = header.slice(7).trim();++ const publicKey = keys.get();++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code, r.reason] };++ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };++ }++ const claims = await userAuth.verify(token);++ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };++ const p = userPrincipal(claims, token);++ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: p };++ }++++ 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();++ }++++ /**++ * The principal gate every index route runs first: anonymous 401, a service token without++ * services.resource.read 403. A user token passes here — what a person may see is the project++ * scope's question (authorizeProject), never this one's.++ */++ function requireRead(req) {++ const p = req.principal;++ if (!p || p.kind === 'anonymous') throw new ServiceError(401, 'token.missing', `${CAPS.resourceRead} needs a Network service or user token`);++ if (p.kind === 'service') {++ const d = capabilityDecision(p.claims, CAPS.resourceRead);++ if (!d.allowed) throw new ServiceError(403, d.code || 'capability.denied', d.reason || `${CAPS.resourceRead} not granted`);++ // First-party only (plan T13 step 4 registers services.resource.read first-party): an app,++ // mod or agent token is refused even if it somehow holds the grant. A person reads with their++ // own user token, never through a developer app's credential.++ if (!String(p.sub || '').startsWith('svc:')) {++ throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: a svc: service token or a person's own user token, never an app, mod or agent token`);++ }++ }++ }++++ /** The first-party gate: a service token with services.resource.read (the authority registry). */++ function requireServiceRead(req) {++ requireRead(req);++ if (req.principal.kind !== 'service') throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: this route needs a service token`);++ }++++ /**++ * The project a read is scoped to (null = every project a first-party caller may see):++ *++ * service any project; ?project= only narrows what a first-party caller sees++ * user only a project they own or belong to, named explicitly — Network answers it with the++ * caller's own token, and a project they are not a member of is 404 (existence is not++ * disclosed), never a 403 that would confirm it exists++ *++ * No person is ever accepted unscoped: a user token reads one project at a time.++ */++ async function authorizeProject(req, project) {++ const p = req.principal;++ if (p.kind === 'service') return { project };++ if (!project) throw new ServiceError(400, 'resources.project_required', 'a user token reads one project at a time: pass ?project=prj_…');++ if (!PROJECT_RE.test(String(project))) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');++ const member = await membership.projectMember(p.token, project);++ if (member === 'unavailable') throw new ServiceError(503, 'network.unavailable', 'OpenVibe.Network could not answer whether this account may read that project');++ if (member !== 'yes') throw new ServiceError(404, 'resources.project_not_found', `no project ${project} for this account`);++ return { project };++ }++++ return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };++}++++module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROPOSED, PROJECT_RE, PRINCIPAL_SUB };nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++++/**++ * One authority's resource API, over its internal loopback origin (ADR-048: the index owns++ * `GET /api/v1/resources`, and every authority answers common.resource-list-result@1 /++ * common.resource-summary@1). One adapter per authority holds nothing but its origin and its token++ * lookup; it stores no resource and writes nothing on the authority.++ *++ * fetchAs(url, opts) the fetch openvibe-sdk/resources' createResourceIndex fans out through: the++ * same request, with that authority's own client-credentials token added. A token++ * Network cannot mint throws here, and the SDK reports this authority partial++ * (ADR-046 section 6) — one authority's failure never fails the whole page.++ * get(ovrn) GET /api/v1/resources/:ovrn → { status, body }; a transport failure is++ * resources.authority_unavailable (502) in the caller's terms.++ */++const { ServiceError } = require('../util');++++const trim = (u) => String(u || '').replace(/\/+$/, '');++++function createAuthorityAdapter(authority, { tokens, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {++ const base = trim(authority.internalOrigin);++++ async function authorization() {++ const token = await tokens.get(authority);++ return token ? { Authorization: `Bearer ${token}` } : {};++ }++++ /** The SDK index's fetch: the authority's token is added per request, never held in the SDK. */++ async function fetchAs(url, opts = {}) {++ return fetchImpl(url, { ...opts, headers: { ...(opts.headers || {}), ...(await authorization()) } });++ }++++ /** GET /api/v1/resources/:ovrn — the one-resource read, straight from the authority that owns it. */++ async function get(ovrn) {++ const url = `${base}/api/v1/resources/${encodeURIComponent(ovrn)}`;++ let res;++ try {++ res = await fetchImpl(url, {++ headers: { Accept: 'application/json', ...(await authorization()) },++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ throw new ServiceError(502, 'resources.authority_unavailable', `${authority.id} did not answer (${err.message})`);++ }++ const body = await res.json().catch(() => null);++ return { status: res.status, body };++ }++++ return { authority, origin: base, fetchAs, get };++}++++/** The one fetch createResourceIndex fans out through: the URL's own authority adds its token. */++function createAuthorityFetcher(adapters) {++ return async (url, opts) => {++ const target = adapters.find((a) => String(url).startsWith(`${a.origin}/`));++ if (!target) throw new Error(`no authority serves ${url}`);++ return target.fetchAs(url, opts);++ };++}++++module.exports = { createAuthorityAdapter, createAuthorityFetcher };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++++/**++ * The authority registry (ADR-048, plan T13 step 6): which services Services may aggregate and call,++ * and where each one listens.++ *++ * An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as++ * `active`. At v0.108.0 that is network, media, events, host and codes; the moment a service's manifest++ * (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is++ * hand-maintained, and nothing here is another service's data. Services itself is never an authority:++ * it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.++ *++ * The loopback origin comes from the manifest (`internalOrigin`, e.g. http://127.0.0.1:4100 for Media);++ * SERVICES_<ID>_URL overrides it for the operator (config.authorityOrigins). The audience Services++ * mints a token for is openvibe.<id> — the Network client-credentials audience of that service.++ */++const contracts = require('openvibe-contracts');++++const RESOURCE_READ = /^([a-z][a-z0-9-]{0,31})\.resource\.read$/;++const trim = (u) => String(u || '').replace(/\/+$/, '');++++function createAuthorities(config, { log = console } = {}) {++ const byId = new Map();++ for (const m of contracts.services.manifests) {++ if (!m || typeof m.id !== 'string') continue;++ if (m.id === 'services') continue; // Services reads authorities, not itself++ if (m.status === 'retired' || m.status === 'placeholder') continue;++ const capId = `${m.id}.resource.read`;++ if (!Array.isArray(m.capabilities) || !m.capabilities.includes(capId)) continue;++ const manifest = RESOURCE_READ.test(capId) ? contracts.capabilities.get(capId) : null;++ if (!manifest || manifest.status !== 'active') continue; // listed but not active: not an authority yet++ const internalOrigin = trim((config.authorityOrigins && config.authorityOrigins[m.id]) || m.internalOrigin);++ if (!internalOrigin) {++ log.warn(`[Services] ${m.id} lists ${capId} but names no internalOrigin: it is left out of the registry`);++ continue;++ }++ byId.set(m.id, Object.freeze({++ id: m.id,++ name: m.name || m.id,++ internalOrigin,++ publicOrigin: m.publicOrigin || null,++ audience: `openvibe.${m.id}`,++ capability: capId,++ manifestVersion: m.version || null,++ contractsRange: (m.contractRanges || {})['openvibe-contracts'] || null,++ status: m.status,++ }));++ }++ const list = () => [...byId.values()];++ return {++ list,++ get: (id) => byId.get(id) || null,++ ids: () => [...byId.keys()],++ size: () => byId.size,++ };++}++++module.exports = { createAuthorities, RESOURCE_READ };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * The token Services presents to an authority: a Network client-credentials token for THAT authority's++ * audience (openvibe.network, openvibe.media, …), scoped to that authority's own resource.read++ * capability. One cached token per audience, refreshed 60 s before expiry, concurrent callers sharing++ * one request — openvibe-sdk/auth's createServiceTokenClient does all of it, and it is the only place a++ * token is minted (nothing here is stored on disk or logged).++ *++ * Unset OV_OAUTH_CLIENT_SECRET in development: no client exists, get() answers null, and every authority++ * that requires a token refuses — the fan-out reports it partial, it never fails the page (ADR-046++ * section 6). Network being down behaves the same way: the mint throws, the authority is stale.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const trim = (u) => String(u || '').replace(/\/+$/, '');++++function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fetch } = {}) {++ const secret = config.oauth.clientSecret;++ const enabled = Boolean(config.oauth.clientId && secret);++ const client = enabled ? createServiceTokenClient({++ tokenUrl: `${trim(config.network.internalUrl)}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: secret,++ // One scope per audience: the authority's own resource.read capability (openvibe-sdk/auth: the++ // scope map is keyed by audience).++ scope: Object.fromEntries(authorities.list().map((a) => [a.audience, a.capability])),++ ...(fetchImpl ? { fetch: fetchImpl } : {}),++ }) : null;++++ return {++ enabled,++ /** The bearer token for one authority's audience; throws when Network cannot mint it. */++ get: async (authority) => (client ? client.getToken({ audience: authority.audience }) : null),++ /** What a caller may show without a token: the audiences it can mint for, and whether it can. */++ status: () => ({ enabled, client_id: config.oauth.clientId, audiences: authorities.list().map((a) => a.audience) }),++ invalidate: (ctx) => { if (client) client.invalidate(ctx); },++ };++}++++module.exports = { createAuthorityTokens };nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++++/**++ * OpenVibe.Services configuration. Everything comes from the environment (.env in development,++ * /etc/openvibe/services.env in production). loadConfig(env) is pure so tests build their own.++ *++ * Services is the resource control plane (ADR-048, plan T13): it reads every authority's resource index++ * and merges it. It owns no other service's data and stores no credential: the authority tokens are++ * Network client-credentials tokens minted per authority audience at call time (server/authorities/tokens.js)++ * from OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, and the person-facing path passes the caller's own++ * Network user token through to Network (server/auth.js).++ */++require('dotenv').config();++++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));++const trim = (u) => String(u || '').replace(/\/+$/, '');++/** A PEM in an environment variable carries its newlines escaped. */++const pem = (v) => (v ? String(v).replace(/\\n/g, '\n') : null);++++/**++ * SERVICES_<ID>_URL overrides one authority's loopback origin (the id uppercased, '-' -> '_'), e.g.++ * SERVICES_NETWORK_URL=http://127.0.0.1:4010. The origin otherwise comes from the service manifest++ * (internalOrigin) of the pinned openvibe-contracts release.++ */++function authorityOrigins(env) {++ const out = {};++ for (const [k, v] of Object.entries(env)) {++ const m = /^SERVICES_([A-Z0-9]+(?:_[A-Z0-9]+)*)_URL$/.exec(k);++ if (m && v) out[m[1].toLowerCase().replace(/_/g, '-')] = trim(v);++ }++ return out;++}++++function loadConfig(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 4930); // 4930 is Services'; 4000-4910 and 4920 are taken++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++ return {++ nodeEnv,++ isProduction,++ port,++ host: env.HOST || '127.0.0.1',++ baseUrl: trim(env.BASE_URL || (isProduction ? 'https://openvibe.services' : `http://localhost:${port}`)),++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,++++ // PostgreSQL (ADR-035): DATABASE_URL is the pooled runtime role through PgBouncer (transaction++ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot.++ db: {++ url: env.DATABASE_URL || '',++ directUrl: env.DATABASE_DIRECT_URL || '',++ },++++ // Identity: the Network signing key verifies every incoming token; NETWORK_URL is the origin the++ // person-facing path asks about project membership (GET /api/v1/projects/:project, with the++ // caller's own token — the developer projects API).++ network: {++ url: networkUrl,++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ publicKey: pem(env.OV_NETWORK_PUBLIC_KEY),++ },++ audience: env.SERVICES_AUDIENCE || 'openvibe.services',++++ // Services' own Network client (`services`): it mints the per-authority client-credentials++ // tokens. Unset in development: the fan-out reaches the authorities without a token and an++ // authority that refuses is reported partial, never fatal (ADR-046 section 6).++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'services',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ },++++ // One authority's loopback origin override (see authorityOrigins); the manifest's internalOrigin++ // is the default.++ authorityOrigins: authorityOrigins(env),++++ // The resource index (plan T13 step 6): the per-authority page size the fan-out asks for, how++ // many authorities are read at once, and how long one authority may take before it is reported++ // partial instead of failing the page. `pageLimit` is the caller-facing default and ceiling.++ index: {++ pageSize: Math.max(1, int(env.SERVICES_INDEX_PAGE_SIZE, 100)),++ concurrency: Math.max(1, int(env.SERVICES_INDEX_CONCURRENCY, 4)),++ timeoutMs: Math.max(1, int(env.SERVICES_INDEX_TIMEOUT_MS, 5000)),++ defaultLimit: Math.max(1, int(env.SERVICES_INDEX_DEFAULT_LIMIT, 100)),++ maxLimit: Math.max(1, int(env.SERVICES_INDEX_MAX_LIMIT, 1000)),++ networkTimeoutMs: Math.max(1, int(env.SERVICES_NETWORK_TIMEOUT_MS, 5000)),++ },++++ // The outbox relay. Services declares no event type yet (plan T13 step 10), so the relay is++ // idle; EVENT_URL unset (or the OAuth client secret unset) keeps rows in the table.++ events: {++ enabled: bool(env.SERVICES_EVENTS_ENABLED, true),++ url: trim(env.EVENTS_URL || ''),++ intervalMs: Math.max(250, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),++ },++ };++}++++module.exports = { loadConfig };nnew file mode 100644@@@ -0,0 +1,40 @@++'use strict';++++/**++ * Services' own PostgreSQL database (ADR-035), through openvibe-sdk/db: async, pooled, one dialect.++ *++ * The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection++ * (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in++ * transaction mode). Services is new: migrations/0001_initial.sql is written as the final schema, no++ * window. Nothing here is another service's data (ADR-048): the authority registry, the rebuildable++ * resource read model, recipes and the control log are Services' own rows.++ *++ * openDb(config) the serving handle: DATABASE_URL; in development without it, an++ * embedded PGlite database in data/pglite (one process)++ * migrate(config, {serving}) apply migrations/ with the owner role, then close that connection++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++function openDb(config, { registry, log = console } = {}) {++ if (!config.db.url && !config.isProduction) {++ log.warn(`[Services] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);++ fs.mkdirSync(DEV_PGLITE, { recursive: true });++ return createDb({ pglite: DEV_PGLITE, service: 'services', registry, log });++ }++ return createDb({ url: config.db.url, service: 'services', registry, log });++}++++/** Apply pending migrations; several processes starting together are serialised by the SDK's lock. */++async function migrate(config, { serving = null, log = console } = {}) {++ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });++ 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: 'services-migrate', max: 1, log });++ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++}++++module.exports = { openDb, migrate, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++++/**++ * Services → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).++ *++ * Services declares no event type yet: ADR-048 puts its control operations and their audit in plan T13++ * step 10, and no `services.*` event is registered in the pinned openvibe-contracts. The outbox is built++ * anyway — the table (services_events_outbox), the relay and Services' own OAuth client — so the first++ * registered type is one string in EVENT_TYPES; until then emit() refuses every envelope and the relay++ * has nothing to send. A row would be written inside the transaction that makes the change, and the++ * relay publishes with Services' service token (events.event.publish, audience openvibe.events).++ */++const { createServiceOutbox } = require('openvibe-sdk/events');++++const TABLE = 'services_events_outbox'; // migrations/0001_initial.sql++const ACTOR = { type: 'service', id: 'services' };++const EVENT_TYPES = [];++++function createServicesOutbox({ db, config, fetchImpl, now, log = console }) {++ return createServiceOutbox({++ db,++ source: 'services',++ table: TABLE,++ eventsUrl: config.events.enabled ? config.events.url : '',++ networkInternalUrl: config.network.internalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ intervalMs: config.events.intervalMs,++ log,++ eventTypes: EVENT_TYPES,++ ...(fetchImpl ? { fetch: fetchImpl } : {}),++ ...(now ? { now } : {}),++ });++}++++module.exports = { createServicesOutbox, TABLE, ACTOR, EVENT_TYPES };nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * OpenVibe.Services — process entry. `node server/index.js`++ * Listens on PORT (4930) behind nginx (openvibe.services); see deploy/.++ *++ * Boot (ADR-035): apply migrations/ with the owner role (DATABASE_DIRECT_URL, one direct connection,++ * closed afterwards), then serve on the pooled runtime role (DATABASE_URL). Several processes may start++ * together: the migration run is serialised by an advisory lock. Nothing starts at module load — the++ * Network key refresher and the outbox relay start here, and the signal handlers stop them in order.++ *++ * The authority registry is built here from the pinned openvibe-contracts service manifests++ * (server/authorities/index.js) and logged, so the boot line says what this instance will fan out over.++ * There is no polling loop: the index reads the authorities on each request (plan T13 step 6), and the++ * rebuildable read model is step 7.++ */++const { loadConfig } = require('./config');++const { openDb, migrate } = require('./db');++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { createRegistry } = require('openvibe-shared/metrics');++++async function main() {++ const config = loadConfig();++ const registry = createRegistry();++ const db = openDb(config, { registry });++ const m = await migrate(config, { serving: db });++ if (m.held.length) console.warn(`[Services] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);++++ const app = createApp({ config, db, registry });++ const { keys, outbox, authorities, tokens } = app.locals;++ keys.start();++ outbox.start();++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[Services] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store})`);++ console.log(`[Services] authorities ${authorities.size()}: ${authorities.ids().join(', ') || 'none'}; authority tokens ${tokens.enabled ? `client ${config.oauth.clientId}` : 'off (OV_OAUTH_CLIENT_SECRET unset: authorities are reported partial)'}; outbox ${outbox.enabled ? `→ ${config.events.url}` : 'idle (no event type declared)'}`);++ });++ server.keepAliveTimeout = 65_000;++ server.headersTimeout = 66_000;++ server.requestTimeout = 40_000;++++ // systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs++ // the stop steps in order (nothing new starts), drains the HTTP server, then the close steps and++ // exits. Services' manifest declares no lifecycle.shutdown deadline, so the kit's default fits.++ const { stop: shutdown } = gracefulStop({++ name: 'Services',++ server,++ drainMs: 4000,++ deadlineMs: 5000,++ deadlineExitCode: 0,++ stop: [++ () => keys.stop(),++ () => outbox.stop(),++ ],++ close: [++ () => db.close().catch(() => {}),++ ],++ });++ return { app, server, shutdown };++}++++if (require.main === module) {++ main().catch((e) => {++ console.error(`[Services] could not start: ${e.message}`);++ process.exit(1);++ });++}++++module.exports = { main };nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++++/**++ * Services' side of OpenVibe.Network: the signing key every incoming token is verified against, and the++ * project-membership question the person-facing index path asks.++ *++ * createKeyProvider(config) Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins++ * it, else the JWKS (GET {internalUrl}/api/.well-known/jwks) refreshed at boot++ * and every six hours; a JWKS outage keeps the last key and never stops the++ * process.++ * createUserAuth(config, keys) offline verification of a person's Network user token (openvibe-sdk/auth:++ * RS256, issuer, expiry; a realtime/stream ticket is never a session).++ * projectMember(config, token, project) does the person this token belongs to own or belong to++ * `project`? Asked of Network's developer projects API++ * (GET /api/v1/projects/:project, viewer+) with the caller's OWN token —++ * Services never holds a project's membership itself (ADR-048: Network owns++ * projects; Services only shows and calls it).++ */++const crypto = require('crypto');++const { verifyUserToken } = require('openvibe-sdk/auth');++++/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;++ let timer = null;++ async function load() {++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;++ try {++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`JWKS ${res.status}`);++ const body = await res.json();++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });++ else throw new Error('JWKS contained no keys');++ return pem;++ } catch (e) {++ log.warn(`[Services] Network key not loaded from ${url}: ${e.message}`);++ return null;++ }++ }++ function start() {++ if (config.network.publicKey || timer) return;++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });++ retry();++ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);++ timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++ return { get: () => pem, load, start, stop };++}++++/**++ * Offline verification of a Network user token (openvibe-sdk/auth: RS256, issuer, expiry, and a typed++ * token — a realtime or stream ticket — is never a session). Returns the claims, or null.++ */++function createUserAuth(config, keys) {++ async function verify(token) {++ if (!token) return null;++ const publicKey = keys.get();++ if (!publicKey) return null;++ try {++ const claims = await verifyUserToken(token, { publicKey, issuer: config.network.issuer });++ return claims && typeof claims === 'object' ? claims : null;++ } catch { return null; }++ }++ return { verify };++}++++/**++ * Does the person who presented `token` own or belong to `project`?++ * Network's developer projects API answers 200 to a viewer-or-higher member and 404 to everyone else++ * (existence is not disclosed), so this returns 'yes' | 'no' | 'unavailable' and the caller answers 404++ * or 503 — never a 403 that would confirm the project exists.++ */++function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {++ async function projectMember(token, projectId) {++ const url = `${config.network.url}/api/v1/projects/${encodeURIComponent(projectId)}`;++ let res;++ try {++ res = await fetchImpl(url, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },++ signal: AbortSignal.timeout(config.index.networkTimeoutMs),++ });++ } catch {++ return 'unavailable';++ }++ try { await res.body?.cancel(); } catch { /* not needed */ }++ if (res.status === 200) return 'yes';++ if (res.status === 404) return 'no';++ // 401/403 from Network means this token is not a Network session (or is revoked): it is not a++ // member of anything Services can vouch for. 5xx and 429 are Network being unable to answer.++ if (res.status === 401 || res.status === 403) return 'no';++ return 'unavailable';++ }++ return { projectMember };++}++++module.exports = { createKeyProvider, createUserAuth, createMembership };nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++++/**++ * Truthful readiness (GET /api/ready) and the Services gauges (GET /metrics, loopback only).++ *++ * db required a real round trip through the pool (db.ready()) — migrations/ runs at boot++ * network_jwks required the Network signing key has loaded; without it no token can be verified and++ * every /api/v1 route answers 503 (server/auth.js), so the service cannot++ * serve its one capability++ *++ * The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades++ * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page,++ * named in X-OpenVibe-Partial-Authorities, not a red readiness — /api/v1/authorities lists the registry++ * as it stands.++ *++ * Gauges: the authorities the registry reads. Counts only, never a token, never a resource.++ */++const { createReadiness } = require('openvibe-shared/ready');++++function createServicesReadiness({ db, keys, authorities, release = null }) {++ return createReadiness({++ service: 'services',++ release,++ checks: [++ { name: 'db', required: true, check: () => db.ready() },++ { name: 'network_jwks', required: true, check: () => (keys.get() ? true : 'Network signing key not loaded yet: no token can be verified') },++ ],++ details: async () => ({ authorities: { count: authorities.size(), ids: authorities.ids() } }),++ });++}++++/** The Services gauges on the openvibe-shared/metrics registry. */++function registerServicesGauges(registry, { authorities }) {++ registry.gauge({++ name: 'services_authorities',++ help: 'Authorities in the registry (services whose manifest lists an active <id>.resource.read)',++ collect: () => authorities.size(),++ });++ return { authorities: () => authorities.size() };++}++++module.exports = { createServicesReadiness, registerServicesGauges };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++++/**++ * The Services resource index (ADR-048, plan T13 step 6):++ *++ * GET /api/v1/resources?project=&kind=&service=&cursor=&limit= every authority's page, merged++ * GET /api/v1/resources/:ovrn one resource, asked of its authority++ *++ * The fan-out, each authority's opaque-cursor walk and the merge are openvibe-sdk/resources'++ * `createResourceIndex` — Services does not reimplement any of it. Services adds:++ *++ * - the authority set (one adapter each, its own client-credentials token; server/authorities/),++ * - a deterministic total order over the merged resources (service, ovrn, id) and an opaque keyset++ * cursor into it, so a page walk never repeats or skips a resource,++ * - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a++ * scoped read (a person's, above all) must never show a row outside its scope even if an authority++ * ignores a query parameter,++ * - and the partial answer. common.resource-list-result@1 has no field for the authorities that++ * could not be read (its only fields are `resources` and `next_cursor`, additionalProperties:++ * false), so a partial page names them in the X-OpenVibe-Partial-Authorities response header —++ * their rows are omitted, the rest of the page is served (ADR-046 section 6: an authority, or++ * Network, being down degrades the index; it never stops it).++ *++ * One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser++ * (contracts.resources.parse), resolves the owning service and asks that authority directly.++ */++const contracts = require('openvibe-contracts');++const { createResourceIndex } = require('openvibe-sdk/resources');++const { createAuthorityAdapter, createAuthorityFetcher } = require('./authorities/adapter');++const { ServiceError, parseOvrn } = require('./util');++++const CURSOR_V = 'v1';++const SEP = '\u0000';++++/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */++const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;++const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);++++const encodeCursor = (key) => Buffer.from(JSON.stringify([CURSOR_V, key])).toString('base64url');++function decodeCursor(raw) {++ let v;++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }++ return Array.isArray(v) && v.length === 2 && v[0] === CURSOR_V && typeof v[1] === 'string' ? v[1] : null;++}++++function createResourceIndexService({ config, authorities, tokens, fetchImpl = globalThis.fetch, log = console }) {++ const adapters = authorities.list().map((a) => createAuthorityAdapter(a, { tokens, fetchImpl, timeoutMs: config.index.timeoutMs }));++ const adapterById = new Map(adapters.map((a) => [a.authority.id, a]));++ const idByOrigin = new Map(adapters.map((a) => [a.origin, a.authority.id]));++ const indexes = new Map();++ let lastPartial = '';++++ /** One SDK index per authority set (all of them, or the one ?service= names), created once. */++ function indexFor(set) {++ const key = set.map((a) => a.authority.id).join(',');++ let index = indexes.get(key);++ if (!index) {++ index = createResourceIndex({++ authorities: set.map((a) => a.origin),++ fetch: createAuthorityFetcher(set),++ pageLimit: config.index.pageSize,++ concurrency: config.index.concurrency,++ timeoutMs: config.index.timeoutMs,++ });++ indexes.set(key, index);++ }++ return index;++ }++++ /**++ * One merged page. `{ resources, next_cursor, partial }` — `partial` lists the authority ids whose++ * rows this page could not include (their cursor walk failed, timed out or was refused).++ */++ async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {++ const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;++ if (service && !set.length) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);++ if (!set.length) return { resources: [], next_cursor: null, partial: [] };++ const { resources, stale } = await indexFor(set).list({ project, kind, limit: config.index.pageSize });++ let merged = resources;++ // The authority filters on its own; this keeps a scoped read scoped even if one does not++ // (project_id is optional on common.resource-summary@1, so a row without one fails a project filter).++ if (service) merged = merged.filter((r) => r.service === service);++ if (kind) merged = merged.filter((r) => r.kind === kind);++ if (project) merged = merged.filter((r) => r.project_id === project);++ merged = merged.slice().sort((a, b) => compare(sortKeyOf(a), sortKeyOf(b)));++ const after = cursor ? decodeCursor(cursor) : null;++ if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');++ const rest = after ? merged.filter((r) => compare(sortKeyOf(r), after) > 0) : merged;++ const page = rest.slice(0, limit);++ const partial = stale.map((s) => idByOrigin.get(s.authority) || s.authority);++ // One line when the set of silent authorities changes — an operator wants to know, a log wants no++ // flood; the caller reads the same fact from the header.++ const signature = partial.join(',');++ if (signature !== lastPartial) {++ if (signature) log.warn(`[Services] index partial: ${signature} did not answer`);++ lastPartial = signature;++ }++ return {++ resources: page,++ next_cursor: rest.length > limit ? encodeCursor(sortKeyOf(page[page.length - 1])) : null,++ partial,++ };++ }++++ /**++ * The OVRN's four segments and the authority that owns it, or ServiceError: 400 resources.bad_name++ * (not an OVRN at all) / 400 resources.unknown_service (no authority of that id). A route authorizes++ * with the returned project BEFORE anything is asked of the authority.++ */++ function resolve(ovrn) {++ const parsed = parseOvrn(ovrn); // contracts.resources.parse, the one parser++ const adapter = adapterById.get(parsed.service);++ if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${parsed.service} is not an authority Services reads`);++ return { ...parsed, adapter };++ }++++ /** The summary whose OVRN this is, from the authority that owns it. Throws ServiceError on refusal. */++ async function get(ovrn) {++ const parsed = resolve(ovrn);++ const { status, body } = await parsed.adapter.get(ovrn);++ if (status === 404) throw new ServiceError(404, 'resources.not_found', `no resource named ${ovrn}`);++ if (status !== 200) throw new ServiceError(502, 'resources.authority_error', `${parsed.service} answered HTTP ${status} for ${ovrn}`);++ const check = contracts.validate('common.resource-summary@1', body);++ if (!check.valid) throw new ServiceError(502, 'resources.authority_bad_response', `${parsed.service} answered something that is not a common.resource-summary@1`);++ if (body.ovrn !== ovrn && contracts.resources.nameOf(body) !== ovrn) {++ throw new ServiceError(502, 'resources.authority_bad_response', `${parsed.service} answered a resource that is not ${ovrn}`);++ }++ return body;++ }++++ /** Drop the cached SDK indexes (an authority set changed at runtime — boot-time registry today). */++ function reset() { indexes.clear(); }++++ return { list, get, resolve, reset, adapters };++}++++module.exports = { createResourceIndexService, encodeCursor, decodeCursor, sortKeyOf };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Small shared pieces: the error a route answers with, and the OVRN helpers every resource route uses. */++const contracts = require('openvibe-contracts');++++/**++ * A refusal this service decided: `status` + a stable problem code + a public detail. The error handler++ * in server/app.js turns it into application/problem+json; anything else is a 500.++ */++class ServiceError extends Error {++ constructor(status, code, detail, extra) {++ super(detail || code);++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.extra = extra;++ }++}++++function fail(status, code, detail, extra) { throw new ServiceError(status, code, detail, extra); }++++/**++ * An OVRN parsed with openvibe-contracts' one parser (contracts.resources.parse — Services never splits++ * on ':'), or a 400 resources.bad_name. `:ovrn` routes use this before anything else.++ */++function parseOvrn(name) {++ const parsed = contracts.resources.parse(name);++ if (!parsed) fail(400, 'resources.bad_name', `${name} is not a resource name (ovrn:<service>:<project_id>:<type>/<id>)`);++ return parsed;++}++++module.exports = { ServiceError, fail, parseOvrn };nnew file mode 100644@@@ -0,0 +1,80 @@++'use strict';++/**++ * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.108.0): the registry is++ * derived from the released service manifests — every non-placeholder service whose manifest lists an++ * active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest++ * loopback origins and their audiences; Services itself, placeholders and services without the++ * capability are not authorities.++ *++ * node test/authorities.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { loadConfig } = require('../server/config');++const { createAuthorities } = require('../server/authorities');++const { check, done } = require('./helpers/app');++++const quiet = { warn() {}, log() {}, error() {} };++++async function main() {++ await check('the pin lists exactly five active <id>.resource.read authorities', () => {++ const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();++ assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read']);++ for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);++ });++++ await check('the registry is built from those manifests, with the manifest origins and audiences', () => {++ const registry = createAuthorities(loadConfig({}), { log: quiet });++ assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);++ const expected = {++ network: 'http://127.0.0.1:4000',++ media: 'http://127.0.0.1:4100',++ events: 'http://127.0.0.1:4300',++ codes: 'http://127.0.0.1:4900',++ host: 'http://127.0.0.1:4910',++ };++ for (const [id, origin] of Object.entries(expected)) {++ const a = registry.get(id);++ assert.ok(a, `${id} is an authority`);++ assert.strictEqual(a.internalOrigin, origin);++ assert.strictEqual(a.audience, `openvibe.${id}`);++ assert.strictEqual(a.capability, `${id}.resource.read`);++ assert.strictEqual(a.name, contracts.services.get(id).name);++ assert.strictEqual(a.manifestVersion, contracts.services.get(id).version);++ assert.strictEqual(a.publicOrigin, contracts.services.get(id).publicOrigin);++ }++ assert.strictEqual(registry.get('nope'), null);++ assert.strictEqual(registry.size(), 5);++ });++++ await check('Services, placeholders and services without the capability are not authorities', () => {++ const registry = createAuthorities(loadConfig({}), { log: quiet });++ for (const id of ['services', 'run', 'actor', 'watch', 'actor-console', 'sites']) {++ if (!contracts.services.get(id)) continue;++ assert.strictEqual(registry.get(id), null, `${id} must not be an authority`);++ }++ // Services' own manifest is a placeholder today; even a live one would never index itself.++ assert.ok(!registry.ids().includes('services'));++ });++++ await check('SERVICES_<ID>_URL overrides one origin, and never invents an authority', () => {++ const registry = createAuthorities(loadConfig({ SERVICES_MEDIA_URL: 'http://127.0.0.1:4999', SERVICES_RUN_URL: 'http://127.0.0.1:4888' }), { log: quiet });++ assert.strictEqual(registry.get('media').internalOrigin, 'http://127.0.0.1:4999');++ assert.strictEqual(registry.get('run'), null, 'an override for a service without the capability changes nothing');++ assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);++ });++++ await check('every registry entry satisfies the rule the registry claims', () => {++ for (const a of createAuthorities(loadConfig({}), { log: quiet }).list()) {++ const service = contracts.services.get(a.id);++ assert.ok(service.capabilities.includes(a.capability), `${a.id} lists its capability`);++ assert.strictEqual(contracts.capabilities.get(a.capability).status, 'active');++ assert.ok(service.internalOrigin, `${a.id} has a loopback origin`);++ assert.ok(!['retired', 'placeholder'].includes(service.status), `${a.id} is served`);++ }++ });++++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++/**++ * server/config.js: everything comes from the environment, loadConfig(env) is pure, and the defaults are++ * Services' own (port 4930, audience openvibe.services, the index's page size, concurrency and timeout).++ *++ * node test/config.test.js++ */++const assert = require('assert');++const { loadConfig } = require('../server/config');++const { check, done } = require('./helpers/app');++++async function main() {++ await check('defaults: port 4930, 127.0.0.1, audience openvibe.services, development', () => {++ const c = loadConfig({});++ assert.strictEqual(c.port, 4930);++ assert.strictEqual(c.host, '127.0.0.1');++ assert.strictEqual(c.nodeEnv, 'development');++ assert.strictEqual(c.isProduction, false);++ assert.strictEqual(c.baseUrl, 'http://localhost:4930');++ assert.strictEqual(c.audience, 'openvibe.services');++ assert.strictEqual(c.network.url, 'https://openvibe.network');++ assert.strictEqual(c.network.internalUrl, 'http://127.0.0.1:4000');++ assert.strictEqual(c.network.issuer, 'https://openvibe.network');++ assert.strictEqual(c.oauth.clientId, 'services');++ assert.strictEqual(c.oauth.clientSecret, '');++ assert.deepStrictEqual(c.index, { pageSize: 100, concurrency: 4, timeoutMs: 5000, defaultLimit: 100, maxLimit: 1000, networkTimeoutMs: 5000 });++ assert.deepStrictEqual(c.authorityOrigins, {});++ assert.strictEqual(c.events.enabled, true);++ assert.strictEqual(c.events.url, '');++ assert.strictEqual(c.db.url, '');++ });++++ await check('the environment overrides every default', () => {++ const c = loadConfig({++ NODE_ENV: 'production', PORT: '5000', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.services/',++ DATABASE_URL: 'postgres://runtime', DATABASE_DIRECT_URL: 'postgres://owner',++ OV_NETWORK_URL: 'https://network.example/', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:4001/',++ OV_NETWORK_ISSUER: 'https://issuer.example', OV_NETWORK_PUBLIC_KEY: '-----BEGIN\\nKEY-----',++ OV_OAUTH_CLIENT_ID: 'svc', OV_OAUTH_CLIENT_SECRET: 'secret',++ SERVICES_INDEX_PAGE_SIZE: '2', SERVICES_INDEX_CONCURRENCY: '1', SERVICES_INDEX_TIMEOUT_MS: '250',++ SERVICES_INDEX_DEFAULT_LIMIT: '10', SERVICES_INDEX_MAX_LIMIT: '20', SERVICES_NETWORK_TIMEOUT_MS: '750',++ SERVICES_MEDIA_URL: 'http://127.0.0.1:4999/', SERVICES_NETWORK_URL: 'http://127.0.0.1:4010',++ EVENTS_URL: 'http://127.0.0.1:4300/', EVENTS_RELAY_INTERVAL_MS: '500', SERVICES_EVENTS_ENABLED: '0',++ });++ assert.strictEqual(c.isProduction, true);++ assert.strictEqual(c.port, 5000);++ assert.strictEqual(c.baseUrl, 'https://openvibe.services'); // a trailing slash is trimmed++ assert.strictEqual(c.db.url, 'postgres://runtime');++ assert.strictEqual(c.network.url, 'https://network.example');++ assert.strictEqual(c.network.internalUrl, 'http://127.0.0.1:4001');++ assert.strictEqual(c.network.issuer, 'https://issuer.example');++ assert.strictEqual(c.network.publicKey, '-----BEGIN\nKEY-----'); // \n escaped in the env++ assert.strictEqual(c.oauth.clientSecret, 'secret');++ assert.deepStrictEqual(c.index, { pageSize: 2, concurrency: 1, timeoutMs: 250, defaultLimit: 10, maxLimit: 20, networkTimeoutMs: 750 });++ assert.deepStrictEqual(c.authorityOrigins, { media: 'http://127.0.0.1:4999', network: 'http://127.0.0.1:4010' });++ assert.strictEqual(c.events.enabled, false);++ assert.strictEqual(c.events.url, 'http://127.0.0.1:4300');++ assert.strictEqual(c.events.intervalMs, 500);++ });++++ await check('a production baseUrl defaults to the domain, and an unnamed authority is not invented', () => {++ const c = loadConfig({ NODE_ENV: 'production' });++ assert.strictEqual(c.baseUrl, 'https://openvibe.services');++ assert.deepStrictEqual(c.authorityOrigins, {});++ const dev = loadConfig({ SERVICES_MEDIA_URL: '' });++ assert.deepStrictEqual(dev.authorityOrigins, {}, 'an empty override is not an origin');++ });++++ await check('loadConfig is pure: it never writes what it read', () => {++ const env = { PORT: '7000' };++ loadConfig(env);++ assert.deepStrictEqual(env, { PORT: '7000' });++ });++++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++/**++ * migrations/0001_initial.sql on a real (PGlite) PostgreSQL: the seven tables Services owns, the++ * idempotency key of services_control_log, the services_resources foreign key to the authority registry,++ * and the outbox table's shape.++ *++ * node test/db.test.js++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { testDb } = require('./helpers/db');++const { check, done } = require('./helpers/app');++++async function main() {++ const store = await testDb();++ const db = store.db;++ try {++ await check('every table of 0001_initial.sql exists', async () => {++ const rows = await db.many("SELECT tablename FROM pg_tables WHERE schemaname = current_schema() ORDER BY tablename");++ const names = rows.map((r) => r.tablename);++ for (const t of ['services_authorities', 'services_resources', 'services_catalog_entries', 'services_recipes', 'services_recipe_runs', 'services_control_log', 'services_events_outbox']) {++ assert.ok(names.includes(t), `${t} exists (have ${names.join(', ')})`);++ }++ });++++ await check('services_authorities takes the registry row and refuses a bad trust or status', async () => {++ await db.query(`INSERT INTO services_authorities (id, name, internal_origin, manifest_version) VALUES ('media', 'OpenVibe.Media', 'http://127.0.0.1:4100', '0.2.0')`);++ const row = await db.one('SELECT id, trust, status FROM services_authorities WHERE id = $1', ['media']);++ assert.deepStrictEqual(row, { id: 'media', trust: 'first-party', status: 'unknown' });++ await assert.rejects(() => db.query(`INSERT INTO services_authorities (id, name, internal_origin, manifest_version, trust) VALUES ('x', 'x', 'http://127.0.0.1:1', '0', 'friend')`));++ });++++ await check('services_resources references the authority registry: no resource of no service', async () => {++ const project = ids.newId('project');++ const object = ids.newId('media');++ await assert.rejects(() => db.query(`INSERT INTO services_resources (ovrn, service, project_id, kind, resource_id, state)++ VALUES ($1, 'ghost', $2, 'ghost.thing', $3, 'active')`, [`ovrn:ghost:${project}:thing/${object}`, project, object]));++ await db.query(`INSERT INTO services_resources (ovrn, service, project_id, kind, resource_id, state)++ VALUES ($1, 'media', $2, 'media.object', $3, 'active')`, [`ovrn:media:${project}:object/${object}`, project, object]);++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_resources'), 1);++ });++++ await check('services_control_log holds one row per idempotency key', async () => {++ const insert = (id, key) => db.query(`INSERT INTO services_control_log (id, action, actor_subject, idempotency_key, request, status)++ VALUES ($1, 'start', $2, $3, '{}'::jsonb, 'pending')`, [id, `user:${ids.newId('user')}`, key]);++ await insert(`ctl_${ids.ulid()}`, 'key-1');++ await assert.rejects(() => insert(`ctl_${ids.ulid()}`, 'key-1')); // the same key, another row: refused++ await insert(`ctl_${ids.ulid()}`, 'key-2');++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_control_log'), 2);++ });++++ await check('services_events_outbox is the openvibe-sdk outbox shape, ready for the first event', async () => {++ const cols = (await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'services_events_outbox'")).map((r) => r.column_name);++ for (const c of ['id', 'event_id', 'envelope', 'traceparent', 'created_at', 'attempts', 'next_attempt_at', 'sent_at', 'seq', 'rejected_at', 'last_error']) {++ assert.ok(cols.includes(c), `outbox column ${c}`);++ }++ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_events_outbox'), 0);++ });++ } finally {++ await store.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++/**++ * Boots Services against the stub Network and the stub authorities a test names, on a random port, with a++ * migrated database of its own (helpers/db.js: PGlite, or the containers with SERVICES_TEST_STORE=pg).++ * The key refresher and the outbox relay are NOT started (server/index.js starts them in production);++ * the JWKS is loaded once, so the app verifies tokens the way it does in production — through the stub++ * Network's own key, never a bypass.++ *++ * const t = await boot({ authorities: [{ id: 'alpha', resources: [...] }, …] });++ * t.call(method, path, { token | cap, sub, project, user, headers }) an HTTP call with a service or++ * user token the stub Network signed++ * t.network the stub Network (mints, signService, signUser, addMember)++ * t.authority('alpha') the stub authority: .url, .calls, .resources++ * t.base, t.db the URL and the migrated database++ *++ * The default page size is deliberately small (SERVICES_INDEX_PAGE_SIZE=2), so a fan-out walks more than++ * one page per authority unless a test says otherwise.++ */++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { testDb } = require('./db');++const { startNetwork, startAuthority, registryOf } = require('./stubs');++++/** A fresh prj_ id (the tenancy boundary of every fixture). */++const newProject = () => ids.newId('project');++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'services-test-'));++ const env = {++ NODE_ENV: 'test',++ BASE_URL: 'http://services.test',++ OV_NETWORK_URL: network.url,++ OV_NETWORK_INTERNAL_URL: network.url,++ OV_NETWORK_ISSUER: network.issuer,++ OV_OAUTH_CLIENT_ID: 'services',++ OV_OAUTH_CLIENT_SECRET: 'services-secret',++ SERVICES_INDEX_TIMEOUT_MS: '400',++ SERVICES_INDEX_PAGE_SIZE: '2', // small: the tests exercise the cursor walk++ SERVICES_INDEX_CONCURRENCY: '2',++ ...(opts.env || {}),++ };++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];++ const { loadConfig } = require('../../server/config');++ const { createApp } = require('../../server/app');++ const config = loadConfig(env);++ const logs = [];++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };++++ const stubs = [];++ for (const a of opts.authorities || []) stubs.push(await startAuthority(a));++ const authorities = opts.registry || registryOf(stubs);++ const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });++ const app = createApp({ config, db: store.db, authorities, log, ...(opts.appOpts || {}) });++ await app.locals.keys.load();++++ const server = await new Promise((resolve) => {++ const s = http.createServer(app);++ s.listen(0, '127.0.0.1', () => resolve(s));++ });++ const base = `http://127.0.0.1:${server.address().port}`;++++ async function call(method, p, { body, token, cap = ['services.resource.read'], sub = 'svc:console', project, user, headers = {} } = {}) {++ const h = { ...headers };++ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ cap, sub, ...(project ? { project_id: project } : {}) }))}`;++ if (body !== undefined) h['Content-Type'] = 'application/json';++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }++ return { status: res.status, headers: res.headers, json, text };++ }++++ return {++ app, base, call, db: store.db, store, config, network, logs, dir,++ authority: (id) => stubs.find((s) => s.id === id) || null,++ authorities: stubs,++ wait: (ms) => new Promise((r) => setTimeout(r, ms)),++ close: async () => {++ server.closeAllConnections();++ await new Promise((r) => server.close(r));++ await app.locals.outbox.stop();++ for (const s of stubs) await s.close();++ await network.close();++ await store.close();++ fs.rmSync(dir, { recursive: true, force: true });++ },++ };++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); }++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done, newProject };nnew file mode 100644@@@ -0,0 +1,74 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035).++ *++ * store 'pglite' (the default): real PostgreSQL in-process, migrated.++ * store 'pg': the production-shaped containers (openvibe-sdk scripts/test-services.sh up), when++ * OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL are set. The run gets its own roles and schema.++ *++ * TIPS-style: SERVICES_TEST_STORE=pg runs every test file on the containers instead of PGlite.++ */++const crypto = require('crypto');++const { createDb } = require('openvibe-sdk/db');++const { MIGRATIONS } = require('../../server/db');++++const quiet = { log() {}, warn() {}, error: (...a) => console.error(...a) };++const pgAvailable = () => !!(process.env.OV_TEST_PG_URL && process.env.OV_TEST_PG_DIRECT_URL);++++async function testDb({ store = process.env.SERVICES_TEST_STORE || 'pglite', max = 4 } = {}) {++ if (store !== 'pg') {++ const db = createDb({ pglite: true, service: 'services-test', log: quiet });++ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });++ return { db, store: 'pglite', close: () => db.close().catch(() => {}) };++ }++ if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');++ const name = `services_t${process.pid}_${crypto.randomBytes(4).toString('hex')}`;++ const owner = `${name}_owner`;++ const pw = crypto.randomBytes(16).toString('hex');++ const su = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'services-test-admin', max: 1, log: quiet });++ const database = await su.value('SELECT current_database()');++ for (const stmt of [++ `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,++ `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,++ `GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${name}`,++ `CREATE SCHEMA ${name} AUTHORIZATION ${owner}`,++ `ALTER ROLE ${owner} SET search_path = ${name}`,++ `ALTER ROLE ${name} SET search_path = ${name}`,++ `ALTER ROLE ${name} SET statement_timeout = '15s'`,++ `ALTER ROLE ${name} SET lock_timeout = '5s'`,++ `GRANT USAGE ON SCHEMA ${name} TO ${name}`,++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,++ ]) await su.query(stmt);++ const as = (url, user) => { const u = new URL(url); u.username = user; u.password = pw; return u.toString(); };++ async function drop() {++ try {++ await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);++ for (let i = 0; i < 100; i++) {++ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);++ if (!Number(left.rows[0].n)) break;++ await new Promise((ok) => setTimeout(ok, 50));++ }++ const ddl = async (stmt) => {++ for (let i = 0; ; i++) {++ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }++ }++ };++ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);++ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }++ } finally { await su.close(); }++ }++ let db;++ try {++ const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'services-test-migrate', max: 1, log: quiet });++ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }++ db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'services-test', max, log: quiet });++ } catch (e) { await drop().catch(() => {}); throw e; }++ return {++ db, store: 'postgresql', schema: name,++ open: (o = {}) => createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'services-test', max, log: quiet, ...o }),++ async close() { await db.close().catch(() => {}); await drop(); },++ };++}++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++/**++ * The two in-process stubs every Services test runs against (no real Network, no real authority):++ *++ * startNetwork() OpenVibe.Network: the JWKS the app's key provider loads, the OAuth token endpoint++ * (grant_type=client_credentials) with EVERY mint recorded — so a test can prove++ * Services mints one token per authority audience and reuses it — and++ * GET /api/v1/projects/:project, the membership answer the person-facing path asks++ * (200 for a member, 404 like Network for everyone else).++ *++ * startAuthority() one authority's resource API: GET /api/v1/resources?project=&kind=&cursor=&limit=++ * (common.resource-list-result@1, paged) and GET /api/v1/resources/:ovrn++ * (common.resource-summary@1 or 404). `hang`, `status` and `latencyMs` make it slow,++ * refusing or unreachable, and `calls` records every request it received (path,++ * query, Authorization) so a test can see which authority was asked what.++ *++ * Fixtures are built with the one formatter (contracts.resources.format): every summary carries a valid++ * OVRN, so what the stubs answer is what a real authority would.++ */++const http = require('http');++const crypto = require('crypto');++const jwt = require('jsonwebtoken');++const { serviceAuth, ids, resources: resourceNames } = require('openvibe-contracts');++++const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));++const b64url = (s) => Buffer.from(JSON.stringify(s)).toString('base64url');++const unb64url = (s) => { try { return JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8')); } catch { return null; } };++const problem = (res, status, code, detail) => {++ res.writeHead(status, { 'Content-Type': 'application/problem+json' });++ res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail }));++};++const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => resolve(b)); });++++/** A common.resource-summary@1 with a valid OVRN (built by the one formatter). `kind` is `<service>.<type>`. */++function summary({ service, kind, project, id = ids.newId('watch'), state = 'active', name, created_at }) {++ return {++ id, kind, service, project_id: project, state,++ name: name || `${kind} ${id.slice(-6)}`,++ created_at: created_at || new Date(Date.parse('2026-01-01T00:00:00Z')).toISOString(),++ ovrn: resourceNames.format({ service, project_id: project, type: kind.split('.')[1], id }),++ };++}++++/**++ * OpenVibe.Network as the two routes Services uses, plus the project-membership answer.++ * `failTokens` makes the token endpoint refuse (5xx): the JWKS still loads and incoming tokens still++ * verify, so a test can watch the authority tokens fail alone.++ */++async function startNetwork({ failTokens = false } = {}) {++ 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 issuer = 'http://network.test';++ const mints = []; // every client_credentials grant: { audience, scope, client_id }++ const members = new Map(); // project -> Set of subject ids++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://network.test');++ if (url.pathname === '/api/.well-known/jwks') {++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));++ }++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const form = new URLSearchParams(await readBody(req));++ if (failTokens) return problem(res, 503, 'oauth.unavailable', 'the token endpoint is down');++ const audience = form.get('audience');++ const scope = form.get('scope') || '';++ mints.push({ audience, scope, client_id: form.get('client_id') });++ if (form.get('grant_type') !== 'client_credentials' || form.get('client_secret') !== 'services-secret') {++ return problem(res, 401, 'oauth.invalid_client', 'not Services');++ }++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ access_token: `svc-token:${audience}:${mints.filter((m) => m.audience === audience).length}`, token_type: 'Bearer', expires_in: 300, scope }));++ }++ if (url.pathname.startsWith('/api/v1/projects/') && req.method === 'GET') {++ const project = decodeURIComponent(url.pathname.slice('/api/v1/projects/'.length));++ const payload = unb64url(String(req.headers.authorization || '').replace(/^Bearer /, '').split('.')[1]);++ const subject = payload && payload.subject_id;++ const mine = members.get(project);++ if (subject && mine && mine.has(subject)) {++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ project: { id: project, name: 'stub', owner: { type: 'user', id: subject } } }));++ }++ return problem(res, 404, 'project.not_found', `no project ${project} for this caller`);++ }++ return problem(res, 404, 'route.not_found', url.pathname);++ });++ const url = await listen(server);++ return {++ url, issuer, publicPem, privatePem, mints, members,++ /** A Network service token (identity.service-token-claims@1) for audience openvibe.services. */++ signService({ sub = 'svc:console', aud = ['openvibe.services'], cap = ['services.resource.read'], project_id, actor_type = 'service', env, on_behalf_of, expSec = 300 } = {}) {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({++ iss: issuer, sub, actor_type, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),++ ...(project_id ? { project_id } : {}), ...(env ? { env } : {}), ...(on_behalf_of ? { on_behalf_of } : {}),++ }, privatePem);++ },++ /** A person's Network user token (a user token names no capability and no project). */++ signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {++ return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, username, display_name: username, role }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });++ },++ /** Add `subject` as a member of `project` (what Network's developer API answers 200 for). */++ addMember(project, subject) { if (!members.has(project)) members.set(project, new Set()); members.get(project).add(subject); },++ mintsFor: (audience) => mints.filter((m) => m.audience === audience),++ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),++ };++}++++/**++ * One authority's resource index (server/authorities/adapter.js talks to exactly these two routes).++ * `ignoreProject` makes it answer every project's rows whatever `?project=` says — a non-conforming++ * authority, so a test can prove Services still keeps a scoped read scoped.++ */++async function startAuthority({ id, resources = [], hang = false, status = null, latencyMs = 0, ignoreProject = false } = {}) {++ const calls = [];++ const server = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://authority.test');++ calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), authorization: req.headers.authorization || null });++ if (hang) return; // never answers: the caller's timeout must decide++ if (status) return problem(res, status, 'authority.refused', `${id} refuses`);++ if (latencyMs) await new Promise((r) => setTimeout(r, latencyMs));++ if (req.method === 'GET' && url.pathname === '/api/v1/resources') {++ const project = url.searchParams.get('project');++ const kind = url.searchParams.get('kind');++ const limit = Number(url.searchParams.get('limit') || 100);++ const offset = Number(unb64url(url.searchParams.get('cursor')) || 0);++ const scoped = ignoreProject ? null : project;++ const rows = resources.filter((r) => (!scoped || r.project_id === scoped) && (!kind || r.kind === kind));++ const page = rows.slice(offset, offset + limit);++ const nextOffset = offset + page.length;++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify({ resources: page, next_cursor: nextOffset < rows.length ? b64url(nextOffset) : null }));++ }++ if (req.method === 'GET' && url.pathname.startsWith('/api/v1/resources/')) {++ const ovrn = decodeURIComponent(url.pathname.slice('/api/v1/resources/'.length));++ const hit = resources.find((r) => r.ovrn === ovrn);++ if (!hit) return problem(res, 404, 'resources.unknown_resource', `no resource named ${ovrn}`);++ res.writeHead(200, { 'Content-Type': 'application/json' });++ return res.end(JSON.stringify(hit));++ }++ return problem(res, 404, 'route.not_found', url.pathname);++ });++ const url = await listen(server);++ return {++ id, url, calls, resources,++ /** The registry entry the app is given in place of a manifest-derived authority. */++ descriptor: {++ id, name: `Stub ${id}`, internalOrigin: url, publicOrigin: `https://${id}.test`,++ audience: `openvibe.${id}`, capability: `${id}.resource.read`, manifestVersion: '0.0.1',++ contractsRange: null, status: 'alpha',++ },++ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),++ };++}++++/** The authority registry shape server/authorities/index.js returns, over stubs (or descriptors). */++function registryOf(entries) {++ const byId = new Map(entries.map((e) => [e.id, e.descriptor || e]));++ return { list: () => [...byId.values()], get: (id) => byId.get(id) || null, ids: () => [...byId.keys()], size: () => byId.size };++}++++module.exports = { startNetwork, startAuthority, registryOf, summary };nnew file mode 100644@@@ -0,0 +1,242 @@++'use strict';++/**++ * The resource index over stub authorities (plan T13 step 6): the fan-out and merge (openvibe-sdk/++ * resources), the merged page and its stable cursor, the project/kind/service filters, the person-facing++ * scoping, an authority that times out or refuses (a partial page, never a failed one), the :ovrn route,++ * and the token cache (one mint per authority).++ *++ * node test/index.test.js++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done, newProject } = require('./helpers/app');++const { summary } = require('./helpers/stubs');++++const ovrnsOf = (body) => body.resources.map((r) => r.ovrn);++++async function main() {++ const prjA = newProject();++ const prjB = newProject();++ const alpha = [++ summary({ service: 'alpha', kind: 'alpha.repo', project: prjA }),++ summary({ service: 'alpha', kind: 'alpha.repo', project: prjB }),++ summary({ service: 'alpha', kind: 'alpha.queue', project: prjA }),++ ];++ const beta = [++ summary({ service: 'beta', kind: 'beta.object', project: prjA }),++ summary({ service: 'beta', kind: 'beta.object', project: prjA }),++ ];++ const gamma = [++ summary({ service: 'gamma', kind: 'gamma.watch', project: prjA }),++ ];++ const t = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }] });++ try {++ await check('the merged page walks every authority with a stable cursor', async () => {++ const seen = [];++ let cursor = null;++ let pages = 0;++ do {++ const r = await t.call('GET', `/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, {});++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), null, 'no authority is partial here');++ assert.ok(r.json.resources.length <= 2, 'limit=2');++ seen.push(...ovrnsOf(r.json));++ cursor = r.json.next_cursor;++ pages++;++ assert.ok(pages <= 6, 'the walk must end');++ } while (cursor);++ assert.strictEqual(pages, 3, 'six resources, two at a time');++ const all = [...alpha, ...beta, ...gamma].map((r) => r.ovrn).sort();++ assert.deepStrictEqual(seen.slice().sort(), all, 'every resource exactly once, none invented');++ assert.deepStrictEqual(seen, all, 'a deterministic (service, ovrn, id) order');++ assert.strictEqual(seen.filter((o) => o.startsWith('ovrn:alpha:')).length, 3);++ });++++ await check('the cursor is stable: the same cursor answers the same page, and repeats nothing', async () => {++ const first = await t.call('GET', '/api/v1/resources?limit=2', {});++ assert.strictEqual(first.status, 200, first.text);++ assert.ok(first.json.next_cursor, 'more pages than one');++ const second = await t.call('GET', `/api/v1/resources?limit=2&cursor=${encodeURIComponent(first.json.next_cursor)}`, {});++ const again = await t.call('GET', `/api/v1/resources?limit=2&cursor=${encodeURIComponent(first.json.next_cursor)}`, {});++ assert.deepStrictEqual(ovrnsOf(second.json), ovrnsOf(again.json), 'the same cursor, the same page');++ assert.strictEqual(ovrnsOf(second.json).length, 2);++ assert.ok(ovrnsOf(second.json).every((o) => !ovrnsOf(first.json).includes(o)), 'no resource repeats across pages');++ const firstAgain = await t.call('GET', '/api/v1/resources?limit=2', {});++ assert.deepStrictEqual(ovrnsOf(firstAgain.json), ovrnsOf(first.json));++ });++++ await check('?service= reads one authority, ?kind= one kind, ?project= one project', async () => {++ const betaCalls = t.authority('beta').calls.length;++ const byService = await t.call('GET', '/api/v1/resources?service=alpha', {});++ assert.strictEqual(byService.status, 200, byService.text);++ assert.deepStrictEqual(ovrnsOf(byService.json), alpha.map((r) => r.ovrn).sort());++ assert.strictEqual(t.authority('beta').calls.length, betaCalls, '?service= never fans out to the others');++++ const byKind = await t.call('GET', '/api/v1/resources?kind=beta.object', {});++ assert.strictEqual(byKind.status, 200, byKind.text);++ assert.deepStrictEqual(ovrnsOf(byKind.json), beta.map((r) => r.ovrn).sort());++++ const byProject = await t.call('GET', `/api/v1/resources?project=${prjB}`, {});++ assert.strictEqual(byProject.status, 200, byProject.text);++ assert.deepStrictEqual(ovrnsOf(byProject.json), [alpha[1].ovrn]);++++ const both = await t.call('GET', `/api/v1/resources?service=alpha&kind=alpha.queue&project=${prjA}`, {});++ assert.strictEqual(both.status, 200, both.text);++ assert.deepStrictEqual(ovrnsOf(both.json), [alpha[2].ovrn]);++++ const unknown = await t.call('GET', '/api/v1/resources?service=nope', {});++ assert.strictEqual(unknown.status, 400, unknown.text);++ assert.strictEqual(unknown.json.code, 'resources.unknown_service');++++ for (const q of ['limit=0', 'limit=100000', 'limit=x', `project=not-a-project`, 'kind=Not A Kind', 'cursor=%25%25', 'service=Bad Service']) {++ const r = await t.call('GET', `/api/v1/resources?${q}`, {});++ assert.strictEqual(r.status, 400, `${q}: ${r.text}`);++ assert.strictEqual(r.json.code, 'resources.bad_query', q);++ }++ });++++ await check('a person reads only projects they belong to, one at a time', async () => {++ const ada = ids.newId('user');++ t.network.addMember(prjA, ada);++++ const unscoped = await t.call('GET', '/api/v1/resources', { token: t.network.signUser({ subject: ada }) });++ assert.strictEqual(unscoped.status, 400, unscoped.text);++ assert.strictEqual(unscoped.json.code, 'resources.project_required');++++ const mine = await t.call('GET', `/api/v1/resources?project=${prjA}`, { token: t.network.signUser({ subject: ada }) });++ assert.strictEqual(mine.status, 200, mine.text);++ const wanted = [...alpha, ...beta, ...gamma].filter((r) => r.project_id === prjA).map((r) => r.ovrn).sort();++ assert.deepStrictEqual(ovrnsOf(mine.json), wanted, 'only prjA rows');++ assert.ok(ovrnsOf(mine.json).includes(alpha[0].ovrn));++ assert.ok(!ovrnsOf(mine.json).includes(alpha[1].ovrn), 'the prjB row of the same authority never shows');++++ const notMine = await t.call('GET', `/api/v1/resources?project=${prjB}`, { token: t.network.signUser({ subject: ada }) });++ assert.strictEqual(notMine.status, 404, notMine.text);++ assert.strictEqual(notMine.json.code, 'resources.project_not_found');++ });++++ await check('an authority that ignores ?project= cannot leak another project into a person page', async () => {++ const lenient = await boot({++ authorities: [{ id: 'alpha', resources: alpha, ignoreProject: true }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }],++ });++ try {++ const ada = ids.newId('user');++ lenient.network.addMember(prjA, ada);++ const r = await lenient.call('GET', `/api/v1/resources?project=${prjA}`, { token: lenient.network.signUser({ subject: ada }) });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!ovrnsOf(r.json).includes(alpha[1].ovrn), 'Services filters a row the authority should not have sent');++ assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));++ } finally { await lenient.close(); }++ });++++ await check('a slow authority is omitted and named; the rest of the page is served', async () => {++ const slow = await boot({++ authorities: [++ { id: 'alpha', resources: alpha },++ { id: 'beta', resources: beta, hang: true },++ { id: 'gamma', resources: gamma },++ ],++ });++ try {++ const r = await slow.call('GET', '/api/v1/resources');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');++ const got = ovrnsOf(r.json);++ assert.deepStrictEqual(got.slice().sort(), [...alpha, ...gamma].map((x) => x.ovrn).sort());++ assert.deepStrictEqual(r.json.resources, r.json.resources.slice().sort((a, b) => (a.ovrn < b.ovrn ? -1 : 1)), 'partial does not disturb the order');++ // the slow authority was asked, so it is partial because it did not answer, not because it was skipped++ assert.ok(slow.authority('beta').calls.length > 0);++ } finally { await slow.close(); }++++ const refusing = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta, status: 503 }] });++ try {++ const r = await refusing.call('GET', '/api/v1/resources');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');++ assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));++ } finally { await refusing.close(); }++ });++++ await check('GET /api/v1/resources/:ovrn asks the owning authority and answers its summary', async () => {++ const target = beta[0];++ const r = await t.call('GET', `/api/v1/resources/${encodeURIComponent(target.ovrn)}`, {});++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json, target);++ const asked = t.authority('beta').calls.filter((c) => c.path === `/api/v1/resources/${encodeURIComponent(target.ovrn)}`);++ assert.strictEqual(asked.length, 1, 'asked exactly once of the owner');++ assert.match(asked[0].authorization, /^Bearer svc-token:openvibe\.beta:/, 'with the authority’s own audience token');++ assert.ok(!t.authority('alpha').calls.some((c) => c.path.includes(target.id)), 'never the other authorities');++++ // the authority's 404 is the caller's 404++ const missing = { ...target, ovrn: `ovrn:beta:${target.project_id}:object/${ids.newId('watch')}` };++ const notFound = await t.call('GET', `/api/v1/resources/${encodeURIComponent(missing.ovrn)}`, {});++ assert.strictEqual(notFound.status, 404, notFound.text);++ assert.strictEqual(notFound.json.code, 'resources.not_found');++ });++++ await check('a bad name or an unknown service in an OVRN is 400', async () => {++ const bad = await t.call('GET', '/api/v1/resources/not-an-ovrn', {});++ assert.strictEqual(bad.status, 400, bad.text);++ assert.strictEqual(bad.json.code, 'resources.bad_name');++++ const unknown = await t.call('GET', `/api/v1/resources/${encodeURIComponent(`ovrn:mystery:${prjA}:thing/${ids.newId('task')}`)}`, {});++ assert.strictEqual(unknown.status, 400, unknown.text);++ assert.strictEqual(unknown.json.code, 'resources.unknown_service');++++ // another project's OVRN parsed for a person who is not a member: 404, before any authority call++ const ada = ids.newId('user');++ const other = await t.call('GET', `/api/v1/resources/${encodeURIComponent(alpha[1].ovrn)}`, { token: t.network.signUser({ subject: ada }) });++ assert.strictEqual(other.status, 404, other.text);++ });++++ await check('the token cache mints one token per authority, and reuses it', async () => {++ // A fresh boot, so the mints counted are this check's own (the cache is per process).++ const fresh = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }] });++ try {++ const first = await fresh.call('GET', '/api/v1/resources');++ assert.strictEqual(first.status, 200, first.text);++ const mints = fresh.network.mints;++ assert.strictEqual(mints.length, 3, 'one mint per authority');++ assert.deepStrictEqual(mints.map((m) => m.audience).sort(), ['openvibe.alpha', 'openvibe.beta', 'openvibe.gamma']);++ assert.deepStrictEqual(mints.map((m) => m.scope).sort(), ['alpha.resource.read', 'beta.resource.read', 'gamma.resource.read']);++ assert.ok(mints.every((m) => m.client_id === 'services'));++++ const second = await fresh.call('GET', '/api/v1/resources');++ assert.strictEqual(second.status, 200, second.text);++ assert.strictEqual(fresh.network.mints.length, 3, 'a second page walk mints nothing new');++++ // and each authority only ever saw its own audience's token++ for (const [id, audience] of [['alpha', 'openvibe.alpha'], ['beta', 'openvibe.beta'], ['gamma', 'openvibe.gamma']]) {++ const auths = new Set(fresh.authority(id).calls.map((c) => c.authorization).filter(Boolean));++ assert.deepStrictEqual([...auths], [`Bearer svc-token:${audience}:1`], `${id} saw only its own token`);++ }++ } finally { await fresh.close(); }++ });++++ await check('a Network that cannot mint a token degrades the page, it never stops it', async () => {++ const broken = await boot({ network: { failTokens: true }, authorities: [{ id: 'alpha', resources: alpha }] });++ try {++ const r = await broken.call('GET', '/api/v1/resources');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'alpha');++ assert.deepStrictEqual(r.json.resources, []);++ } finally { await broken.close(); }++ });++++ await check('GET /api/v1/authorities lists the registry a service token reads', async () => {++ const r = await t.call('GET', '/api/v1/authorities', {});++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.authorities.map((a) => a.id).sort(), ['alpha', 'beta', 'gamma']);++ const a = r.json.authorities.find((x) => x.id === 'beta');++ assert.strictEqual(a.origin, t.authority('beta').url);++ assert.strictEqual(a.audience, 'openvibe.beta');++ assert.strictEqual(a.capability, 'beta.resource.read');++ assert.ok(!JSON.stringify(r.json).includes('services-secret'), 'never a secret');++ });++ } finally {++ await t.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,14 @@++#!/usr/bin/env node++/**++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any of++ * them fails. They use an in-process stub of OpenVibe.Network and in-process stub authorities, temp++ * databases and random ports; none needs the network or a running service.++ *++ * npm test # everything (PGlite)++ * npm run test:pg # everything on the containers (SERVICES_TEST_STORE=pg)++ * npm test -- index scaffold # only files whose name contains one of the words++ */++'use strict';++// A file's wall timeout is generous: the test host is shared with other jobs, so a healthy file can take++// several times its usual time. Files still run one at a time (parallel: 1) to keep the host's load down.++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 28, parallel: 1 });nnew file mode 100644@@@ -0,0 +1,142 @@++'use strict';++/**++ * The kernel around the resource index (plan T13 step 3): liveness, truthful readiness, the release++ * manifest, the loopback-only /metrics, the problem+json surface and the token rule on the index.++ *++ * node test/scaffold.test.js++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++async function main() {++ const t = await boot();++ try {++ await check('liveness: GET /api/health says only what it can back', async () => {++ const r = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.ok, true);++ assert.strictEqual(r.json.service, 'services');++ assert.strictEqual(r.json.version, require('../package.json').version);++ assert.strictEqual(r.json.authorities, 0); // no authority in this boot++ assert.strictEqual(r.json.events.enabled, false); // EVENTS_URL unset in the test++ assert.strictEqual(r.json.events.pending, 0); // no event type declared yet++ });++++ await check('readiness: GET /api/ready reports the database and the Network key, and nothing else', async () => {++ const r = await t.call('GET', '/api/ready', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.ready, true, r.text);++ assert.strictEqual(r.json.status, 'ready', r.text);++ assert.strictEqual(r.json.service, 'services');++ assert.strictEqual(r.json.checks.db.status, 'ok');++ assert.strictEqual(r.json.checks.db.required, true);++ assert.strictEqual(r.json.checks.network_jwks.status, 'ok'); // the stub Network's key is loaded++ assert.deepStrictEqual(r.json.failed, []);++ assert.deepStrictEqual(r.json.skipped, [], 'readiness claims nothing it did not read');++ assert.deepStrictEqual(r.json.authorities, { count: 0, ids: [] });++ });++++ await check('GET /release.json serves the manifest for this release', async () => {++ const r = await t.call('GET', '/release.json', { token: null });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.service, 'services');++ assert.strictEqual(r.json.contracts_version, require('openvibe-contracts/package.json').version);++ assert.strictEqual(r.json.packages['openvibe-sdk'], require('openvibe-sdk/package.json').version);++ assert.ok(r.json.components.server, 'the release names its server component');++ });++++ await check('GET /metrics is for a direct loopback caller, never a forwarded one', async () => {++ const direct = await t.call('GET', '/metrics', { token: null });++ assert.strictEqual(direct.status, 200, direct.text);++ assert.match(direct.headers.get('content-type') || '', /text\/plain/);++ assert.match(direct.text, /services_authorities/);++ assert.match(direct.text, /release_info/);++ for (const header of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'forwarded']) {++ const forwarded = await t.call('GET', '/metrics', { token: null, headers: { [header]: '203.0.113.7' } });++ assert.strictEqual(forwarded.status, 404, `${header} must not reach /metrics`);++ }++ const under = await t.call('GET', '/metrics/nope', { token: null });++ assert.strictEqual(under.status, 404);++ });++++ await check('an unknown route is a problem+json 404, page or API', async () => {++ for (const p of ['/api/v1/nope', '/console', '/', '/nope']) {++ const r = await t.call('GET', p, { token: null });++ assert.strictEqual(r.status, 404, `${p}: ${r.text}`);++ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/, p);++ assert.strictEqual(r.json.code, 'route.not_found', p);++ assert.ok(r.json.request_id, p);++ }++ });++++ await check('the index without a token is 401 token.missing', async () => {++ const ovrn = `ovrn:alpha:${ids.newId('project')}:task/${ids.newId('task')}`;++ // An OVRN carries a '/', so a path parameter spells it URL-encoded (as the console will).++ for (const p of ['/api/v1/resources', `/api/v1/resources/${encodeURIComponent(ovrn)}`, '/api/v1/authorities']) {++ const r = await t.call('GET', p, { token: null });++ assert.strictEqual(r.status, 401, `${p}: ${r.text}`);++ assert.strictEqual(r.json.code, 'token.missing');++ }++ });++++ await check('a service token without services.resource.read is 403 capability.denied', async () => {++ const r = await t.call('GET', '/api/v1/resources', { cap: ['media.object.read'] });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'capability.denied');++ });++++ await check('an app token is refused even with the grant: the index is first-party service tokens or a person', async () => {++ const project = ids.newId('project');++ const appToken = t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', project_id: project, env: 'production' });++ const r = await t.call('GET', `/api/v1/resources?project=${project}`, { token: appToken });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'capability.denied');++ assert.match(r.json.detail, /first-party/);++ });++++ await check('a token for another audience is refused, never downgraded', async () => {++ const r = await t.call('GET', '/api/v1/resources', { token: t.network.signService({ aud: ['openvibe.media'] }) });++ assert.strictEqual(r.status, 401, r.text);++ assert.ok(['token.wrong_audience', 'token.invalid'].includes(r.json.code), r.text);++ });++++ await check('a person with no project is 400 resources.project_required, never an unscoped read', async () => {++ const r = await t.call('GET', '/api/v1/resources', { user: {} });++ assert.strictEqual(r.status, 400, r.text);++ assert.strictEqual(r.json.code, 'resources.project_required');++ });++++ await check('the authority registry is first-party: a person does not get it', async () => {++ const r = await t.call('GET', '/api/v1/authorities', { user: {} });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json.code, 'capability.denied');++ });++++ await check('the skeleton sends the security headers an API should', async () => {++ const r = await t.call('GET', '/api/health', { token: null });++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');++ assert.ok(r.headers.get('x-frame-options'));++ assert.strictEqual(r.headers.get('x-powered-by'), null);++ assert.match(r.headers.get('x-openvibe-request-id') || '', /^req_[0-9a-f]{24}$/);++ });++ } finally {++ await t.close();++ }++++ // A second boot with a broken registry input: an empty authority set answers an empty page, not a crash.++ const empty = await boot({ env: { OV_OAUTH_CLIENT_SECRET: '' } });++ try {++ await check('with no OAuth secret the index still serves (an empty registry is an empty page)', async () => {++ const r = await empty.call('GET', '/api/v1/resources', {});++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json, { resources: [], next_cursor: null });++ assert.strictEqual(empty.app.locals.tokens.enabled, false);++ });++ } finally {++ await empty.close();++ }++ done();++}++++main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+# ═══════════════════════════════════════════════════════════════
+# openvibe.services — Environment Configuration
+# Copy to .env for development. In production this lives at /etc/openvibe/services.env (0600).
+# Names only here; never commit a value.
+# ═══════════════════════════════════════════════════════════════
+
+# Server
+PORT=4930
+HOST=127.0.0.1
+NODE_ENV=development
+BASE_URL=http://localhost:4930
+TRUST_PROXY=1
+
+# ── Data: PostgreSQL (ADR-035) ──────────────────────────────
+# Host's roles/add-service.sh writes both in production. Unset in development: an embedded PGlite
+# database in data/pglite (one process). Tests need neither.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+
+# ── Identity: OpenVibe.Network ──────────────────────────────
+OV_NETWORK_URL=https://openvibe.network
+# The origin Services asks about project membership (GET /api/v1/projects/:project, with the caller's
+# own user token) and reads the signing key from (GET /api/.well-known/jwks).
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+#OV_NETWORK_ISSUER=https://openvibe.network
+# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.
+#OV_NETWORK_PUBLIC_KEY=
+# Audience Services answers for: incoming service tokens must carry it.
+SERVICES_AUDIENCE=openvibe.services
+# Services' own Network client: it mints one client-credentials token per authority audience
+# (openvibe.network, openvibe.media, …), scoped to that authority's resource.read capability. Unset in
+# development: the fan-out calls the authorities without a token and every authority that requires one
+# is reported partial — the index degrades, it never stops (ADR-046 section 6).
+OV_OAUTH_CLIENT_ID=services
+OV_OAUTH_CLIENT_SECRET=
+
+# ── The authorities (plan T13 step 6) ───────────────────────
+# An authority is a service whose pinned openvibe-contracts manifest lists <id>.resource.read as
+# active; its loopback origin comes from that manifest. SERVICES_<ID>_URL overrides one for the
+# operator (the id uppercased, '-' -> '_'):
+#SERVICES_NETWORK_URL=http://127.0.0.1:4000
+#SERVICES_MEDIA_URL=http://127.0.0.1:4100
+#SERVICES_EVENTS_URL=http://127.0.0.1:4300
+#SERVICES_CODES_URL=http://127.0.0.1:4900
+#SERVICES_HOST_URL=http://127.0.0.1:4910
+
+# ── The resource index ──────────────────────────────────────
+# The per-authority page size the fan-out asks for, how many authorities are read at once, and how long
+# one authority may take before its rows are omitted and it is named in X-OpenVibe-Partial-Authorities.
+SERVICES_INDEX_PAGE_SIZE=100
+SERVICES_INDEX_CONCURRENCY=4
+SERVICES_INDEX_TIMEOUT_MS=5000
+# The caller-facing page size: default and ceiling.
+SERVICES_INDEX_DEFAULT_LIMIT=100
+SERVICES_INDEX_MAX_LIMIT=1000
+# How long the membership question to Network may take before the person-facing path answers 503.
+SERVICES_NETWORK_TIMEOUT_MS=5000
+
+# ── OpenVibe.Events ─────────────────────────────────────────
+# Services declares no event type yet (plan T13 step 10), so the relay is idle. Set EVENTS_URL and the
+# OAuth secret and it publishes the first registered services.* event the moment EVENT_TYPES has one.
+EVENTS_URL=
+#SERVICES_EVENTS_ENABLED=1
+#EVENTS_RELAY_INTERVAL_MS=2000
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ # contracts-service: services — Services' own manifest is a placeholder until plan T13 step 4 registers
+ # it (and services.resource.read, PROPOSED in server/auth.js until then), so the check reads this
+ # repository's use of the pinned contracts; the contract ids it validates today: common.resource-summary@1.
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ contracts-service: services
+ # 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 on the
+ # containers through PgBouncer (SERVICES_TEST_STORE=pg).
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+node_modules/
+# A linked dependency tree is a symlink, which the pattern above (directory-only) does not match: the
+# tooling that links one shared copy into a worktree must never be committable.
+node_modules
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+# gitleaks configuration, used by the shared security workflow
+# (OpenVibers/OpenVibe.Shared/.github/workflows/security.yml). The default rules stay on; every
+# allowlist below is one proven false positive, matched on path AND value so nothing else is hidden.
+#
+# Services stores no credential of its own: the authority tokens are Network client-credentials tokens
+# minted at call time from OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET (.env.example carries names only)
+# and never written to disk, a log line or a response body. Nothing here needs an allowlist yet.
+[extend]
+useDefault = true
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+# Changelog
+
+Notable changes to OpenVibe.Services. The service is unreleased; everything below is `0.1.0` in progress.
+
+## Unreleased
+
+### Added
+
+- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite in
+ development, PostgreSQL through PgBouncer in production), `server/app.js`, `server/index.js`,
+ `server/network.js` (the Network key provider, user-token verification, project membership),
+ `server/auth.js` (`services.resource.read` in a `PROPOSED` set — plan T13 step 4 registers it),
+ `server/observability.js` (`/api/health`, truthful `/api/ready`, loopback-only `/metrics`),
+ `server/events/outbox.js` (idle: no `services.*` event type is registered yet),
+ `migrations/0001_initial.sql` (the seven tables Services owns), `STATUS.json`, the nginx vhost and the
+ systemd unit.
+- The authority registry (plan T13 step 6, `server/authorities/index.js`): built at boot from the pinned
+ openvibe-contracts service manifests — every non-placeholder service whose manifest lists an `active`
+ `<id>.resource.read` capability, with its manifest `internalOrigin` (`SERVICES_<ID>_URL` overrides) and
+ audience `openvibe.<id>`. With contracts v0.108.0: network, events, host, codes, media. Services itself is
+ never an authority.
+- One adapter per authority (`server/authorities/adapter.js`) and the per-authority token
+ (`server/authorities/tokens.js`): `GET /api/v1/resources` and `GET /api/v1/resources/:ovrn` over the
+ authority's loopback origin with a Network client-credentials token for its audience, scoped to its
+ `resource.read` capability and cached (one mint per authority).
+- The merged resource index (plan T13 step 6): `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=`
+ fans out and merges every authority's pages with `openvibe-sdk/resources`' `createResourceIndex`, adds a
+ deterministic (service, OVRN, id) order and an opaque keyset cursor, and filters the merged rows itself so a
+ scoped read stays scoped. `GET /api/v1/resources/:ovrn` resolves the OVRN with contracts' one parser and asks
+ the authority that owns it. `GET /api/v1/authorities` lists the registry (first-party).
+- Partial pages: a slow, refusing or unreachable authority — or a Network that cannot mint its token — never
+ fails the page; its rows are omitted and it is named in `X-OpenVibe-Partial-Authorities`
+ (ADR-046 section 6). `common.resource-list-result@1` has no field for it; the contract gap is recorded in the
+ README.
+- The person-facing path: a Network user token reads only projects the person owns or belongs to, one at a time,
+ with membership answered by Network's developer projects API using the caller's own token (non-members get the
+ same 404 Network gives). Never an unscoped person, and never through an app, mod or agent token — the service
+ path is `svc:` principals holding `services.resource.read` only.
+
+### Notes
+
+- Services aggregates authorities and owns no other service's rows (ADR-048): it never opens, writes or migrates
+ another service's database, and every control operation (plan T13 step 10) will be a call to the owning
+ authority.
+- `services.resource.read` is not registered in openvibe-contracts yet; it is enforced from the `PROPOSED` set in
+ `server/auth.js` with contracts' own grant rules, so step 4 registers exactly what this service enforces.
+- The repository is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_services` database,
+ `/etc/openvibe/services.env` and `openvibe.services` DNS (see STATUS.json).
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -0,0 +1,149 @@
+# OpenVibe.Services
+
+The resource control plane of the network (openvibe.services): one merged index over every authority, and — as plan
+T13 lands — the console and the control operations on top of it. Design: OpenVibe.Contracts
+docs/adr/ADR-048-services-control-plane.md; plan track T13.
+
+## Purpose
+
+Every service that owns resources answers `GET /api/v1/resources` with a `common.resource-list-result@1` page of
+`common.resource-summary@1` (`ADR-048`; the resource-index sweep adds that route to each service in turn). Services is
+the one place that fans out over those authorities, merges their pages, and answers a single index — so a console,
+an operator or a developer sees every resource of every project they may see without any service learning another
+service's schema.
+
+**Services aggregates authorities; it never replaces one.** It reads each authority's resource index and keeps at
+most a read model it can rebuild; it never opens, writes or migrates another service's database, and every control
+operation (plan T13 step 10) will be a call to the authority that owns the resource. An authority, or Network, being
+down degrades the index into a partial page — it never stops it (ADR-046 §6).
+
+This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out index. The read model and ingestion
+(step 7), the console (step 9), the control operations (step 10), recipes (step 11) and the usage/cost surfaces
+(step 12) are not built yet; this service declares them nowhere.
+
+## Routes
+
+| Method and path | Capability | Request → answer |
+|---------------------------------------------------|---------------------------|------------------|
+| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in `X-OpenVibe-Partial-Authorities` |
+| `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) |
+| `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) |
+| `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest |
+| `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |
+
+`services.resource.read` is **PROPOSED**: it is not registered in the pinned openvibe-contracts yet (plan T13 step 4
+does that after this repository exists). It lives in `server/auth.js` in a `PROPOSED` set and is enforced with
+contracts' own grant rules (`capabilities.grants`, wildcards included), so what step 4 registers is exactly what this
+service enforces. `services.resource.control` arrives with step 10.
+
+Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |
+resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,
+`404 resources.not_found`, `502 resources.authority_unavailable | resources.authority_error |
+resources.authority_bad_response`, `503 network.unavailable | identity.unavailable`.
+
+## Who may read the index
+
+- **A Network service token** for audience `openvibe.services`, `svc:` principal, holding `services.resource.read`
+ (first-party): the whole index, over every authority and every project; `?project=` only narrows it. An app, mod
+ or agent token is refused even if it somehow holds the grant — a person reads with their own user token, never
+ through a developer app's credential.
+- **A person's Network user token**: only projects that person owns or belongs to, and only ONE at a time —
+ `?project=prj_…` (or the OVRN's own project) is required, and Network answers the membership question
+ (`GET /api/v1/projects/:project`, the developer projects API) with the caller's own token. A project the person is
+ not a member of is `404 resources.project_not_found`, the same non-disclosure Network itself uses — never a `403`
+ that would confirm the project exists. A person is never accepted unscoped.
+
+`GET /api/v1/authorities` is first-party operational data (loopback origins) and answers only a service token.
+
+## The merged index
+
+The fan-out, each authority's opaque-cursor walk and the merge are `openvibe-sdk/resources`' `createResourceIndex`
+(SDK v0.34.0, `docs/service.md` §"The resource index and the control plane") — Services does not reimplement any of
+it. Services adds:
+
+- **the authority set** — one adapter per authority (`server/authorities/adapter.js`), each calling that authority's
+ loopback origin with its own Network client-credentials token (`server/authorities/tokens.js`: one cached token per
+ audience, scoped to that authority's `resource.read` capability, refreshed 60 s before expiry);
+- **a deterministic total order** over the merged resources (service, OVRN, id) and an opaque keyset cursor into it,
+ so a page walk repeats nothing and skips nothing while the underlying data is stable;
+- **local filters** for `project`, `kind` and `service` on top of the authorities' own answers: a scoped read (a
+ person's, above all) never shows a row outside its scope even if an authority ignores a query parameter;
+- **the partial answer**. `common.resource-list-result@1` has no field for the authorities that could not be read
+ (its only properties are `resources` and `next_cursor`, `additionalProperties: false`), so a page whose fan-out was
+ incomplete omits those rows and names the authorities in the `X-OpenVibe-Partial-Authorities` response header.
+ **Contract gap (for a later Contracts release):** a `common.resource-list-result@1` field for the partial
+ authorities (e.g. `stale: [{ service, status, code }]`, or a `partial: true` and a separate list) would let a
+ client read the degradation from the body instead of a header.
+
+An authority is never required to answer for the index to answer: a timeout (default 5 s per authority), a refusal,
+a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page
+intact.
+
+## The authority registry
+
+`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:
+every service whose manifest lists `<id>.resource.read` **and** whose capability manifest says `status: active` is an
+authority. With contracts v0.108.0 that is **network, events, host, codes and media**; the moment a service's
+manifest turns that on, it is in the registry on the next boot, and nothing here is hand-maintained. Services itself
+is never an authority. Each authority's loopback origin comes from its manifest (`internalOrigin`);
+`SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is `openvibe.<id>`.
+
+There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and
+`*.resource.*` ingestion are step 7.
+
+## Owns
+
+- The authority registry (derived from the pinned Contracts manifests, not stored) and the merged resource index.
+- Its own database (ADR-035): `migrations/0001_initial.sql` — `services_authorities`, `services_resources`,
+ `services_catalog_entries`, `services_recipes`, `services_recipe_runs`, `services_control_log`,
+ `services_events_outbox`. All of it is the console's own state or a read model it can rebuild; no table is another
+ service's.
+
+## Does not own
+
+- Any other service's rows, bytes, grants, events or decisions: every resource belongs to its authority, and every
+ control operation (step 10) will be a `common.resource-control-request@1` to that authority.
+- Identity, projects, apps, keys and nodes: OpenVibe.Network. Services asks Network's developer API with the caller's
+ own token and never caches a membership decision beyond the request.
+- The persons and tokens themselves: Services verifies the tokens it is the audience of (`openvibe.services`) and
+ passes a user token through to Network only for the membership question.
+
+## Depends on
+
+- PostgreSQL 18 (ADR-035): `migrations/` applies at boot on the owner role; the service then serves on the pooled
+ runtime role. Unset in development: PGlite in `data/pglite`.
+- OpenVibe.Network: the RS256 signing key (`GET /api/.well-known/jwks`), Services' OAuth client (`services`) for the
+ per-authority tokens, and the developer projects API for membership.
+- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at the
+ current pin. Every one of them is optional to serve, by design.
+- `openvibe-contracts` v0.108.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0 (package.json).
+
+## Tests
+
+```bash
+npm test # every file on PGlite, one process at a time
+npm run test:pg # the same on PostgreSQL + PgBouncer containers
+node test/index.test.js # one file
+```
+
+No test needs the network or a running service: `test/helpers/stubs.js` is an in-process OpenVibe.Network (JWKS,
+`/oauth/token` with every mint recorded, project membership) and in-process authorities whose pages, timeouts and
+refusals the tests drive. The index tests cover the merged page walk with a stable cursor, the filters, the
+person-scoped path (including an authority that ignores `?project=`), a hanging and a refusing authority, the OVRN
+route, the malformed-name and unknown-service refusals, and one token mint per authority.
+
+## Deployment
+
+`deploy/systemd/openvibe-services.service` (WorkingDirectory `/opt/openvibe.services`, EnvironmentFile
+`/etc/openvibe/services.env`, PORT 4930) and `deploy/nginx/openvibe.services.conf`. Not deployed yet: no DNS, no
+`ovhost` entry, no `ov_services` database and no `/etc/openvibe/services.env`. The first deploy needs Services'
+Network client (`OV_OAUTH_CLIENT_ID=services` with its secret) granted every authority's `<id>.resource.read` scope —
+otherwise every authority refuses and the index is partial (correctly, and visibly).
+
+## Versions
+
+<!-- versions:start -->
+- openvibe-contracts: v0.108.0
+- openvibe-sdk: v0.34.0
+- openvibe-shared: v2.11.0
+<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+# 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, projects, resources or data.
+
+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.services, openvibe.network, openvibe.live, openvibe.media, openvibe.run,
+openvibe.bot 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.
+
+## This service
+
+OpenVibe.Services aggregates every authority's resource index and (from plan T13 step 10) routes control operations
+to them, so a defect here can expose another project's resources or act on them. What Services guarantees, and what
+a report should assume:
+
+- **no other service's data is written.** Services never opens, writes or migrates another service's database. Its
+ own rows are migrations/0001_initial.sql — the authority registry, a rebuildable read model, recipes and the
+ control log.
+- **a person reads only their own projects.** A Network user token is accepted only for a project the person owns or
+ belongs to, verified with the caller's own token against Network's developer projects API
+ (`GET /api/v1/projects/:project`); a project they are not a member of is `404`, never a `403` that confirms it
+ exists. A person is never accepted unscoped, and the fan-out passes only that one project to the authorities.
+- **a scoped read stays scoped.** Services filters `project` (and `kind`, `service`) on the merged rows itself, so an
+ authority that ignores a query parameter cannot leak another project's resource into a person's page.
+- **a service token is judged on its own claims.** Audience `openvibe.services`, `svc:` principal, capability
+ `services.resource.read` (PROPOSED until plan T13 step 4 registers it — enforced with contracts' own grant rules).
+ A bad token is refused, never downgraded to anonymous, and an app, mod or agent token is refused even with the
+ grant: a person reads with their own user token, never through a developer app's credential.
+- **no secret is stored, logged or served.** The per-authority client-credentials tokens are minted at call time,
+ cached in memory with a 60 s refresh skew and never written to disk, a log line or a response body. The only route
+ that shows operational detail, `GET /api/v1/authorities`, is first-party (a service token) and carries no secret.
+- **a failure degrades, it never stops.** An authority or Network being down yields a partial page named in
+ `X-OpenVibe-Partial-Authorities`; the rest of the index keeps serving (ADR-046 section 6).
+- nothing starts at module load (the key refresher and the outbox relay start in `server/index.js` `main()`), and
+ `/metrics` answers only a direct loopback caller that no proxy relayed.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+{
+ "repository": "OpenVibers/OpenVibe.Services",
+ "stage": "alpha",
+ "code": true,
+ "wave": "W13",
+ "plan": "OpenVibe Updated Plan Based On Codebase (plan T13, ADR-048)",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
+ "port": 4930,
+ "domain": "openvibe.services",
+ "contracts": "openvibe-contracts v0.108.0",
+ "sdk": "openvibe-sdk v0.34.0",
+ "shared": "openvibe-shared v2.11.0",
+ "packages": [
+ "openvibe-contracts v0.108.0",
+ "openvibe-sdk v0.34.0",
+ "openvibe-shared v2.11.0"
+ ],
+ "capabilities": [
+ "services.resource.read"
+ ],
+ "capabilitiesRegistered": false,
+ "capabilitiesNote": "PROPOSED (server/auth.js): services.resource.read is not in the pinned openvibe-contracts yet; plan T13 step 4 registers it (and the later services.resource.control, services.recipe.run, services.usage.read, services.cost.read) after this repository exists. Services enforces exactly the id it will register.",
+ "events": [],
+ "deployed": false,
+ "deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /api/, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.",
+ "features": [
+ "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.108.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",
+ "one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",
+ "the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",
+ "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the X-OpenVibe-Partial-Authorities header — common.resource-list-result@1 has no field for it (ADR-046 section 6; the contract gap is recorded in the README)",
+ "GET /api/v1/resources/:ovrn routes the name with openvibe-contracts' one parser to the authority that owns it and answers its common.resource-summary@1; a malformed name or an unknown service is 400, the authority's 404 is 404, anything else is 502",
+ "GET /api/v1/authorities: the registry as it stands (id, origin, audience, capability, manifest version) — Services-local, no contract yet",
+ "who may read: a first-party svc: Network service token holding services.resource.read, or a person's user token scoped to ONE project they own or belong to, verified with the caller's own token against Network's developer projects API (GET /api/v1/projects/:project; non-members are 404, existence is not disclosed). Never an unscoped person, and never an app, mod or agent token even with the grant",
+ "the kernel: config/db (PGlite in development, PostgreSQL through PgBouncer in production; migrations applied at boot), truthful /api/ready (db and network_jwks required; a down authority is a partial page, not a red readiness), loopback-only /metrics, /api/health, /release.json, RFC 9457 problem+json everywhere, and the openvibe-sdk outbox (idle: no services.* event type is registered yet, plan T13 step 10)",
+ "migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"
+ ],
+ "notYet": [
+ "plan T13 step 4 (OpenVibe.Contracts registers the services.* capabilities, the service manifest leaves placeholder), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"
+ ],
+ "updated": "2026-10-07"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.services — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.services.conf — keep them in sync and reload nginx
+## after changing it. The Node app (port 4930, systemd unit openvibe-services) serves:
+##
+## /api/v1/resources… the resource index (services.resource.read); every request needs a
+## Network service token for audience openvibe.services, or a person's
+## user token scoped to one project they own or belong to
+## /api/health, /api/ready, /release.json
+## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
+## /internal/ host-local service endpoints, refused here
+##
+## The console (plan T13 step 9) is not served yet: today every other path is a 404 problem+json.
+##
+## The client address reaches the app as $remote_addr only, same in X-Forwarded-For and CF-Connecting-IP:
+## the header is set here, never appended to or passed through from the client, so the app's own view of
+## who is calling cannot be spoofed.
+##
+## No proxy_cache: the index answers no-store (a partial page must never be cached as if it were whole).
+## Never log request bodies or the Authorization header: a service token and a person's token both ride
+## in one, and the index passes the caller's token to no one but Network (project membership).
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=ovservices_api:10m rate=20r/s;
+
+# ── HTTP → HTTPS ────────────────────────────────────────────
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.services www.openvibe.services;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.services$request_uri; }
+}
+
+# ── www → apex ──────────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.services;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.services$request_uri;
+}
+
+# ── The service ─────────────────────────────────────────────
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.services;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.services/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.services/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.services.access.log;
+ error_log /var/log/nginx/openvibe.services.error.log;
+
+ # The API answers query strings and small JSON bodies only (server/app.js, 64 kB).
+ client_max_body_size 1m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin 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 application/json application/problem+json text/plain;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr; # the client address, from $remote_addr only
+ proxy_set_header X-Forwarded-For $remote_addr; # never appended to, never passed through
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Health, readiness, the release manifest ─────────────
+ location = /api/health { proxy_pass http://127.0.0.1:4930; }
+ location = /api/ready { proxy_pass http://127.0.0.1:4930; }
+ location = /release.json { proxy_pass http://127.0.0.1:4930; }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4930/metrics) ──
+ location = /metrics { return 404; }
+
+ # ── Host-local service endpoints ────────────────────────
+ location /internal/ { return 404; }
+
+ # ── The resource index ──────────────────────────────────
+ # The fan-out is slow by nature (it reads every authority): give it a generous read timeout while
+ # the app's own per-authority timeout keeps one slow authority from holding the page (it lands in
+ # X-OpenVibe-Partial-Authorities instead).
+ location /api/ {
+ limit_req zone=ovservices_api burst=40 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:4930;
+ proxy_read_timeout 30s;
+ }
+
+ # ── Nothing else is served yet (the console is plan T13 step 9) ──
+ location / { return 404; }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+[Unit]
+Description=OpenVibe.Services — the resource control plane (authority registry, resource index; plan T13, ADR-048)
+# Network (sign-in, JWKS, OAuth, projects) should be up first, and Services' own PostgreSQL (pooled
+# runtime role plus the direct owner role for migrations) is required to serve. The authorities are
+# NOT required: an authority being down degrades the index into a partial page, it never stops Services
+# (ADR-046 section 6), so nothing here Wants their units.
+After=network.target postgresql.service pgbouncer.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.services
+EnvironmentFile=/etc/openvibe/services.env
+Environment=NODE_ENV=production
+Environment=PORT=4930
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.services/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# Services reads no file from under its own tree at request time (the migration directory is read at
+# boot) and holds no credential of its own: the authority tokens are minted from OV_OAUTH_CLIENT_SECRET
+# in /etc/openvibe/services.env (0600) and are never written anywhere.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+-- phase: expand
+-- OpenVibe.Services' own rows (ADR-048, plan T13). Services aggregates authorities and owns no other
+-- service's data: every table here is the console's own state or a read model it can rebuild from the
+-- authorities' resource indexes and events. No table here is another service's, and Services never
+-- opens, writes or migrates another service's database.
+--
+-- Nothing in this migration is another authority's truth:
+-- services_authorities the authority registry's own record (id, origin, trust, last seen); the
+-- registry Services serves is built at boot from the released
+-- openvibe-contracts service manifests (server/authorities/index.js) and
+-- uses this table only to remember what it has seen (plan T13 step 7+).
+-- services_resources the rebuildable read model of resources other services own (step 7).
+-- services_catalog_entries the product catalog move out of OpenVibe.Sites (step 14).
+-- services_recipes, services_recipe_runs onboarding recipes (step 11).
+-- services_control_log one row per control operation Services routed to an authority (step 10);
+-- the authority's own answer is the truth, this is the console's record.
+-- services_events_outbox the openvibe-sdk transactional outbox; Services declares no event type
+-- yet (step 10), so the relay is idle and rows would wait here.
+CREATE TABLE IF NOT EXISTS services_authorities (
+ id text PRIMARY KEY, -- contracts service id: network, media, events, host, codes, …
+ name text NOT NULL,
+ public_origin text,
+ internal_origin text NOT NULL,
+ manifest_version text NOT NULL,
+ contracts_range text,
+ trust text NOT NULL DEFAULT 'first-party' CHECK (trust IN ('first-party','partner','community','external')),
+ status text NOT NULL DEFAULT 'unknown' CHECK (status IN ('unknown','up','degraded','down','draining')),
+ last_seen_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS services_resources (
+ ovrn text PRIMARY KEY, -- common.resource-name@1
+ service text NOT NULL REFERENCES services_authorities(id),
+ project_id text NOT NULL,
+ kind text NOT NULL, -- <service>.<type>
+ resource_id text NOT NULL,
+ name text,
+ state text NOT NULL,
+ owner_subject text,
+ usage jsonb NOT NULL DEFAULT '{}'::jsonb,
+ links jsonb NOT NULL DEFAULT '{}'::jsonb,
+ authority_updated_at timestamptz,
+ seen_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS services_resources_project_idx ON services_resources (project_id, kind, ovrn);
+CREATE INDEX IF NOT EXISTS services_resources_service_idx ON services_resources (service, seen_at);
+CREATE TABLE IF NOT EXISTS services_catalog_entries (
+ id text PRIMARY KEY, -- product domain slug
+ domain text NOT NULL,
+ status text NOT NULL CHECK (status IN ('live','planned','parked')),
+ product jsonb NOT NULL, -- registry.product@1
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS services_recipes (
+ id text PRIMARY KEY,
+ name text NOT NULL,
+ version integer NOT NULL DEFAULT 1,
+ description text NOT NULL,
+ steps jsonb NOT NULL, -- [{ id, authority, action, params, needs[] }]
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE TABLE IF NOT EXISTS services_recipe_runs (
+ id text PRIMARY KEY, -- rcp_<ULID>
+ recipe_id text NOT NULL REFERENCES services_recipes(id),
+ project_id text NOT NULL,
+ subject text NOT NULL,
+ state text NOT NULL CHECK (state IN ('running','done','failed','cancelled')),
+ steps jsonb NOT NULL DEFAULT '[]'::jsonb,
+ started_at timestamptz NOT NULL DEFAULT now(),
+ finished_at timestamptz
+);
+CREATE TABLE IF NOT EXISTS services_control_log (
+ id text PRIMARY KEY, -- ctl_<ULID>
+ ovrn text,
+ action text NOT NULL,
+ actor_subject text NOT NULL,
+ project_id text,
+ idempotency_key text NOT NULL,
+ request jsonb NOT NULL,
+ response jsonb,
+ status text NOT NULL CHECK (status IN ('done','pending','refused','failed')),
+ at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (idempotency_key)
+);
+-- The openvibe-sdk outbox shape (outboxSchema('services_events_outbox')): written inside the change's
+-- own transaction when Services has an event to emit, relayed to OpenVibe.Events.
+CREATE TABLE IF NOT EXISTS services_events_outbox (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ event_id text NOT NULL UNIQUE,
+ envelope jsonb NOT NULL,
+ traceparent text,
+ created_at bigint NOT NULL,
+ attempts integer NOT NULL DEFAULT 0,
+ next_attempt_at bigint NOT NULL DEFAULT 0,
+ sent_at bigint,
+ seq bigint,
+ rejected_at bigint,
+ last_error text
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1284 @@
@@ -0,0 +1,1284 @@
+{
+ "name": "openvibe-services",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-services",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "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/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/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-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "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/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/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.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.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.108.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
+ "integrity": "sha512-T07n7B482/XIdSD3ikehPGHFUTWsyGaeLCF2SerWsKD1V09s1RYs7TD9+Y2z7vdxYjSYhQPMHr1r2xP3F9xIbA==",
+ "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.34.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "integrity": "sha512-vDwnDV1e2xbWqShjTr4p6sNehoUAQwW8RyQKrwvoqX1pJbxks86u2DjF9LGSlAoAdMZhmJbgVaAEm0r18Qmt5g==",
+ "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.11.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
+ "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/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/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+{
+ "name": "openvibe-services",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Services — the resource control plane (plan T13, ADR-048): the authority registry, the resource index fanned out over every authority, and (later) the console and control operations.",
+ "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": "SERVICES_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe",
+ "services",
+ "resources",
+ "control-plane"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "repository": "github:OpenVibers/OpenVibe.Services",
+ "dependencies": {
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "jsonwebtoken": "^9.0.2"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * /api/v1 — the Services resource index (ADR-048, plan T13 step 6). The route → capability map:
+ *
+ * | Method & path | Capability | Request → answer |
+ * |----------------------------|----------------------------|-----------------------------------------------------------|
+ * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; the authorities a page could not read are named in X-OpenVibe-Partial-Authorities) |
+ * | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |
+ * | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |
+ *
+ * services.resource.read is PROPOSED (server/auth.js): it is not in the pinned openvibe-contracts yet
+ * (plan T13 step 4 registers it), and Services enforces exactly the id it will register.
+ *
+ * Who may call: a Network service token holding services.resource.read (any project, ?project= only
+ * narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their
+ * own token against Network (server/auth.js authorizeProject). Never an unscoped person.
+ *
+ * Errors are RFC 9457 problem+json: 400 resources.bad_query / resources.bad_name /
+ * resources.unknown_service / resources.project_required, 401 token.missing, 403 capability.denied,
+ * 404 resources.not_found, 502 resources.authority_unavailable / resources.authority_error, 503
+ * network.unavailable.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { ServiceError } = require('../util');
+
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SERVICE_RE = /^[a-z][a-z0-9-]{0,31}$/;
+const KIND_RE = /^[a-z][a-z0-9-]{0,31}\.[a-z][a-z0-9-]{0,39}$/;
+
+/** The list query, or a 400. An absent filter is null; a malformed one is refused, never ignored. */
+function listQuery(query, config) {
+ const out = { project: null, kind: null, service: null, cursor: null, limit: config.index.defaultLimit };
+ if (query.project != null && query.project !== '') {
+ if (typeof query.project !== 'string' || !PROJECT_RE.test(query.project)) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');
+ out.project = query.project;
+ }
+ if (query.kind != null && query.kind !== '') {
+ if (typeof query.kind !== 'string' || !KIND_RE.test(query.kind)) throw new ServiceError(400, 'resources.bad_query', 'kind must be <service>.<type>');
+ out.kind = query.kind;
+ }
+ if (query.service != null && query.service !== '') {
+ if (typeof query.service !== 'string' || !SERVICE_RE.test(query.service)) throw new ServiceError(400, 'resources.bad_query', 'service must be a service id');
+ out.service = query.service;
+ }
+ if (query.cursor != null && query.cursor !== '') {
+ // The shape is checked here; whether it is a cursor THIS index issued is the index's own check
+ // (server/resources.js decodeCursor), so there is one cursor format and one place that knows it.
+ if (typeof query.cursor !== 'string' || query.cursor.length > 2048) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
+ out.cursor = query.cursor;
+ }
+ if (query.limit != null && query.limit !== '') {
+ if (typeof query.limit !== 'string' || !/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > config.index.maxLimit) {
+ throw new ServiceError(400, 'resources.bad_query', `limit must be an integer 1-${config.index.maxLimit}`);
+ }
+ out.limit = Number(query.limit);
+ }
+ return out;
+}
+
+function v1Router({ config, apiAuth, index, authorities }) {
+ const r = express.Router();
+
+ // One merged page over every authority (or the one ?service= names). A failing authority's rows are
+ // omitted and named in the partial header; the rest of the page is served.
+ r.get('/resources', async (req, res) => {
+ apiAuth.requireRead(req);
+ const filter = listQuery(req.query, config);
+ const scope = await apiAuth.authorizeProject(req, filter.project);
+ const page = await index.list({ ...filter, project: scope.project });
+ if (page.partial.length) res.set('X-OpenVibe-Partial-Authorities', page.partial.join(', '));
+ res.json({ resources: page.resources, next_cursor: page.next_cursor });
+ });
+
+ // One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),
+ // the owning service resolved (400), and the caller authorized for its project — a person must be a
+ // member of that project; a service token needs services.resource.read. Nothing is asked of the
+ // authority before the caller is authorized.
+ r.get('/resources/:ovrn', async (req, res) => {
+ apiAuth.requireRead(req);
+ const ovrn = String(req.params.ovrn);
+ const { project_id: project } = index.resolve(ovrn);
+ await apiAuth.authorizeProject(req, project);
+ res.json(await index.get(ovrn));
+ });
+
+ // The authorities Services reads, and where they listen. First-party operational data (loopback
+ // origins), so a service token with services.resource.read — not the person-facing path. A
+ // Services-local answer: no contract names it yet.
+ r.get('/authorities', async (req, res) => {
+ apiAuth.requireServiceRead(req);
+ res.json({
+ authorities: authorities.list().map((a) => ({
+ id: a.id, name: a.name, origin: a.internalOrigin, public_origin: a.publicOrigin,
+ audience: a.audience, capability: a.capability, manifest_version: a.manifestVersion,
+ contracts_range: a.contractsRange, status: a.status,
+ })),
+ });
+ });
+
+ r.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { ctx: req.ov }));
+ return r;
+}
+
+module.exports = { v1Router, listQuery };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * OpenVibe.Services — the resource control plane (ADR-048, plan T13). Express app factory;
+ * server/index.js listens, tests build their own instance. Everything is injectable and nothing starts
+ * at module load (the key refresher and the outbox relay start in server/index.js main()).
+ *
+ * GET /api/health, /api/ready, /release.json liveness, truthful readiness, the release manifest
+ * GET /metrics direct loopback callers only (never proxied)
+ * /api/v1/* the resource index (api/v1.js: one capability per route)
+ *
+ * createApp({ config, db, keys, userAuth, membership, authorities, tokens, index, outbox, registry,
+ * fetchImpl, now, log })
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const rateLimit = require('express-rate-limit');
+const { http } = require('openvibe-contracts');
+const { loadConfig } = require('./config');
+const { openDb } = require('./db');
+const { createKeyProvider, createUserAuth, createMembership } = require('./network');
+const { createAuthorities } = require('./authorities');
+const { createAuthorityTokens } = require('./authorities/tokens');
+const { createResourceIndexService } = require('./resources');
+const { createServicesOutbox } = require('./events/outbox');
+const { createApiAuth } = require('./auth');
+const { v1Router } = require('./api/v1');
+const { createServicesReadiness, registerServicesGauges } = require('./observability');
+const { ServiceError } = require('./util');
+const { createRegistry, instrument } = require('openvibe-shared/metrics');
+
+const VERSION = require('../package.json').version;
+// The API takes query strings and (later) small JSON bodies; nothing here uploads.
+const JSON_LIMIT = '64kb';
+
+function createApp(opts = {}) {
+ const config = opts.config || loadConfig();
+ const db = opts.db || openDb(config);
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const log = opts.log || console;
+ const now = opts.now || (() => Date.now());
+ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
+ const userAuth = createUserAuth(config, keys);
+ const membership = opts.membership || createMembership(config, { fetchImpl });
+ const authorities = opts.authorities || createAuthorities(config, { log });
+ const tokens = opts.tokens || createAuthorityTokens(config, { authorities, fetchImpl });
+ const index = opts.index || createResourceIndexService({ config, authorities, tokens, fetchImpl, log });
+ const outbox = opts.outbox || createServicesOutbox({ db, config, fetchImpl, now, log });
+ const apiAuth = createApiAuth({ config, keys, userAuth, membership });
+ const release = require('openvibe-shared/release').createRelease({ service: 'services', root: path.join(__dirname, '..') });
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ // Services answers JSON only for now (the console, plan T13 step 9, will widen this): helmet's
+ // defaults carry what matters and there is no page yet whose CSP would need widening.
+ app.use(helmet({ contentSecurityPolicy: false }));
+
+ const registry = opts.registry || createRegistry();
+ registerServicesGauges(registry, { authorities });
+ const metrics = instrument(app, { service: 'services', release: release.release, registry });
+ app.use(http.middleware());
+
+ // Liveness: the process answers. What it can and cannot do is /api/ready's job, and the counts are
+ // /metrics' and /api/ready's — this route says nothing it cannot back with a value it just read.
+ app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
+ ok: true, service: 'services', version: VERSION, authorities: authorities.size(), events,
+ }), next));
+ const readiness = createServicesReadiness({ db, keys, authorities, release: release.release });
+ app.get('/api/ready', readiness.handler);
+ release.mount(app, { registry: metrics.registry });
+
+ app.use('/api/v1',
+ // A per-address cap: the fan-out reaches every authority, so a caller loop here is every
+ // authority's load too; a refusal is an RFC 9457 problem like every other answer, never text.
+ rateLimit({
+ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false,
+ handler: (req, res) => http.sendProblem(res, 429, 'services.rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
+ }),
+ express.json({ limit: JSON_LIMIT }),
+ (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
+ apiAuth.middleware,
+ v1Router({ config, apiAuth, index, authorities }));
+
+ // No page is served yet (the console is plan T13 step 9): every unknown route, page or API, is the
+ // same problem+json.
+ app.use((req, res) => http.sendProblem(res, 404, 'route.not_found', { ctx: req.ov }));
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, next) => {
+ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
+ if (err instanceof ServiceError && !res.headersSent) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov, extra: err.extra });
+ log.error('[Services] unhandled error:', err);
+ if (res.headersSent) return undefined;
+ return http.sendProblem(res, 500, 'services.internal', { ctx: req.ov });
+ });
+
+ Object.assign(app.locals, { config, db, keys, userAuth, membership, authorities, tokens, index, outbox, apiAuth, metrics, registry });
+ return app;
+}
+
+module.exports = { createApp, VERSION, JSON_LIMIT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — resolved into req.principal:
+ *
+ * { kind: 'service', sub: 'svc:live', cap: [...], claims, project_id, jti } a Network client-credentials
+ * token for audience openvibe.services; the index routes
+ * check services.resource.read (server/api/v1.js).
+ * { kind: 'user', subject: 'usr_…', username, token } a person's Network user token (SSO). A person
+ * reads ONLY projects they own or belong to, verified with
+ * their own token against Network (server/network.js
+ * projectMember), and only one project at a time.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
+ *
+ * Capabilities: Services' capability ids are not registered in openvibe-contracts yet (plan T13 step 4
+ * registers them after this repository exists). They live here in PROPOSED, and PROPOSED ids are decided
+ * with contracts' own grant rules (wildcards included) so that what step 4 registers is exactly what
+ * Services enforces. Once an id is registered, the contracts manifest decides, unchanged.
+ */
+const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
+const { ServiceError } = require('./util');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const ANON = Object.freeze({ kind: 'anonymous' });
+
+/**
+ * The capabilities Services enforces (PROPOSED: no manifest in the pinned openvibe-contracts yet).
+ * services.resource.read is the whole read side of the resource index (list, one, the authority list).
+ * services.resource.control arrives with plan T13 step 10.
+ */
+const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
+const PROPOSED = Object.freeze(new Set(Object.values(CAPS)));
+
+/** One capability against a token's claims: contracts' manifest where there is one, the PROPOSED grant rules where not. */
+function capabilityDecision(claims, id) {
+ if (!PROPOSED.has(id)) return capabilities.check(claims, id);
+ const allowed = capabilities.grants(claims && claims.cap, id);
+ return allowed
+ ? { allowed: true, code: null, reason: null }
+ : { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
+}
+
+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; }
+}
+
+/** Verify a service token (audience openvibe.services). Returns { ok, claims } or { ok:false, code, reason }. */
+function verifyService(token, { publicKey, issuer, audience }) {
+ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
+ const payload = decodePayload(token);
+ if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
+ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
+ return { ok: true, claims: r.claims };
+}
+
+/** A Network user token's claims → the user principal (null when it names no canonical subject). */
+function userPrincipal(claims, token) {
+ if (!claims) return null;
+ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
+ if (!subject) return null;
+ return {
+ kind: 'user', subject, token, username: claims.username || null,
+ name: claims.display_name || claims.username || null, role: claims.role || 'user', cap: [], claims,
+ };
+}
+
+/** The project a principal names, or null. */
+const projectOf = (principal) => {
+ const id = principal && principal.claims && principal.claims.project_id;
+ return typeof id === 'string' && PROJECT_RE.test(id) ? id : null;
+};
+
+function createApiAuth({ config, keys, userAuth, membership }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return { principal: ANON };
+ const token = header.slice(7).trim();
+ const publicKey = keys.get();
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const r = verifyService(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code, r.reason] };
+ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], claims: r.claims, project_id: projectOf({ claims: r.claims }), jti: r.claims.jti } };
+ }
+ const claims = await userAuth.verify(token);
+ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
+ const p = userPrincipal(claims, token);
+ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: p };
+ }
+
+ 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();
+ }
+
+ /**
+ * The principal gate every index route runs first: anonymous 401, a service token without
+ * services.resource.read 403. A user token passes here — what a person may see is the project
+ * scope's question (authorizeProject), never this one's.
+ */
+ function requireRead(req) {
+ const p = req.principal;
+ if (!p || p.kind === 'anonymous') throw new ServiceError(401, 'token.missing', `${CAPS.resourceRead} needs a Network service or user token`);
+ if (p.kind === 'service') {
+ const d = capabilityDecision(p.claims, CAPS.resourceRead);
+ if (!d.allowed) throw new ServiceError(403, d.code || 'capability.denied', d.reason || `${CAPS.resourceRead} not granted`);
+ // First-party only (plan T13 step 4 registers services.resource.read first-party): an app,
+ // mod or agent token is refused even if it somehow holds the grant. A person reads with their
+ // own user token, never through a developer app's credential.
+ if (!String(p.sub || '').startsWith('svc:')) {
+ throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: a svc: service token or a person's own user token, never an app, mod or agent token`);
+ }
+ }
+ }
+
+ /** The first-party gate: a service token with services.resource.read (the authority registry). */
+ function requireServiceRead(req) {
+ requireRead(req);
+ if (req.principal.kind !== 'service') throw new ServiceError(403, 'capability.denied', `${CAPS.resourceRead} is first-party: this route needs a service token`);
+ }
+
+ /**
+ * The project a read is scoped to (null = every project a first-party caller may see):
+ *
+ * service any project; ?project= only narrows what a first-party caller sees
+ * user only a project they own or belong to, named explicitly — Network answers it with the
+ * caller's own token, and a project they are not a member of is 404 (existence is not
+ * disclosed), never a 403 that would confirm it exists
+ *
+ * No person is ever accepted unscoped: a user token reads one project at a time.
+ */
+ async function authorizeProject(req, project) {
+ const p = req.principal;
+ if (p.kind === 'service') return { project };
+ if (!project) throw new ServiceError(400, 'resources.project_required', 'a user token reads one project at a time: pass ?project=prj_…');
+ if (!PROJECT_RE.test(String(project))) throw new ServiceError(400, 'resources.bad_query', 'project must be a prj_ id');
+ const member = await membership.projectMember(p.token, project);
+ if (member === 'unavailable') throw new ServiceError(503, 'network.unavailable', 'OpenVibe.Network could not answer whether this account may read that project');
+ if (member !== 'yes') throw new ServiceError(404, 'resources.project_not_found', `no project ${project} for this account`);
+ return { project };
+ }
+
+ return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
+}
+
+module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROPOSED, PROJECT_RE, PRINCIPAL_SUB };
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+
+/**
+ * One authority's resource API, over its internal loopback origin (ADR-048: the index owns
+ * `GET /api/v1/resources`, and every authority answers common.resource-list-result@1 /
+ * common.resource-summary@1). One adapter per authority holds nothing but its origin and its token
+ * lookup; it stores no resource and writes nothing on the authority.
+ *
+ * fetchAs(url, opts) the fetch openvibe-sdk/resources' createResourceIndex fans out through: the
+ * same request, with that authority's own client-credentials token added. A token
+ * Network cannot mint throws here, and the SDK reports this authority partial
+ * (ADR-046 section 6) — one authority's failure never fails the whole page.
+ * get(ovrn) GET /api/v1/resources/:ovrn → { status, body }; a transport failure is
+ * resources.authority_unavailable (502) in the caller's terms.
+ */
+const { ServiceError } = require('../util');
+
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+
+function createAuthorityAdapter(authority, { tokens, fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
+ const base = trim(authority.internalOrigin);
+
+ async function authorization() {
+ const token = await tokens.get(authority);
+ return token ? { Authorization: `Bearer ${token}` } : {};
+ }
+
+ /** The SDK index's fetch: the authority's token is added per request, never held in the SDK. */
+ async function fetchAs(url, opts = {}) {
+ return fetchImpl(url, { ...opts, headers: { ...(opts.headers || {}), ...(await authorization()) } });
+ }
+
+ /** GET /api/v1/resources/:ovrn — the one-resource read, straight from the authority that owns it. */
+ async function get(ovrn) {
+ const url = `${base}/api/v1/resources/${encodeURIComponent(ovrn)}`;
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ headers: { Accept: 'application/json', ...(await authorization()) },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ throw new ServiceError(502, 'resources.authority_unavailable', `${authority.id} did not answer (${err.message})`);
+ }
+ const body = await res.json().catch(() => null);
+ return { status: res.status, body };
+ }
+
+ return { authority, origin: base, fetchAs, get };
+}
+
+/** The one fetch createResourceIndex fans out through: the URL's own authority adds its token. */
+function createAuthorityFetcher(adapters) {
+ return async (url, opts) => {
+ const target = adapters.find((a) => String(url).startsWith(`${a.origin}/`));
+ if (!target) throw new Error(`no authority serves ${url}`);
+ return target.fetchAs(url, opts);
+ };
+}
+
+module.exports = { createAuthorityAdapter, createAuthorityFetcher };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+
+/**
+ * The authority registry (ADR-048, plan T13 step 6): which services Services may aggregate and call,
+ * and where each one listens.
+ *
+ * An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as
+ * `active`. At v0.108.0 that is network, media, events, host and codes; the moment a service's manifest
+ * (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is
+ * hand-maintained, and nothing here is another service's data. Services itself is never an authority:
+ * it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.
+ *
+ * The loopback origin comes from the manifest (`internalOrigin`, e.g. http://127.0.0.1:4100 for Media);
+ * SERVICES_<ID>_URL overrides it for the operator (config.authorityOrigins). The audience Services
+ * mints a token for is openvibe.<id> — the Network client-credentials audience of that service.
+ */
+const contracts = require('openvibe-contracts');
+
+const RESOURCE_READ = /^([a-z][a-z0-9-]{0,31})\.resource\.read$/;
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+
+function createAuthorities(config, { log = console } = {}) {
+ const byId = new Map();
+ for (const m of contracts.services.manifests) {
+ if (!m || typeof m.id !== 'string') continue;
+ if (m.id === 'services') continue; // Services reads authorities, not itself
+ if (m.status === 'retired' || m.status === 'placeholder') continue;
+ const capId = `${m.id}.resource.read`;
+ if (!Array.isArray(m.capabilities) || !m.capabilities.includes(capId)) continue;
+ const manifest = RESOURCE_READ.test(capId) ? contracts.capabilities.get(capId) : null;
+ if (!manifest || manifest.status !== 'active') continue; // listed but not active: not an authority yet
+ const internalOrigin = trim((config.authorityOrigins && config.authorityOrigins[m.id]) || m.internalOrigin);
+ if (!internalOrigin) {
+ log.warn(`[Services] ${m.id} lists ${capId} but names no internalOrigin: it is left out of the registry`);
+ continue;
+ }
+ byId.set(m.id, Object.freeze({
+ id: m.id,
+ name: m.name || m.id,
+ internalOrigin,
+ publicOrigin: m.publicOrigin || null,
+ audience: `openvibe.${m.id}`,
+ capability: capId,
+ manifestVersion: m.version || null,
+ contractsRange: (m.contractRanges || {})['openvibe-contracts'] || null,
+ status: m.status,
+ }));
+ }
+ const list = () => [...byId.values()];
+ return {
+ list,
+ get: (id) => byId.get(id) || null,
+ ids: () => [...byId.keys()],
+ size: () => byId.size,
+ };
+}
+
+module.exports = { createAuthorities, RESOURCE_READ };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * The token Services presents to an authority: a Network client-credentials token for THAT authority's
+ * audience (openvibe.network, openvibe.media, …), scoped to that authority's own resource.read
+ * capability. One cached token per audience, refreshed 60 s before expiry, concurrent callers sharing
+ * one request — openvibe-sdk/auth's createServiceTokenClient does all of it, and it is the only place a
+ * token is minted (nothing here is stored on disk or logged).
+ *
+ * Unset OV_OAUTH_CLIENT_SECRET in development: no client exists, get() answers null, and every authority
+ * that requires a token refuses — the fan-out reports it partial, it never fails the page (ADR-046
+ * section 6). Network being down behaves the same way: the mint throws, the authority is stale.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+
+function createAuthorityTokens(config, { authorities, fetchImpl = globalThis.fetch } = {}) {
+ const secret = config.oauth.clientSecret;
+ const enabled = Boolean(config.oauth.clientId && secret);
+ const client = enabled ? createServiceTokenClient({
+ tokenUrl: `${trim(config.network.internalUrl)}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: secret,
+ // One scope per audience: the authority's own resource.read capability (openvibe-sdk/auth: the
+ // scope map is keyed by audience).
+ scope: Object.fromEntries(authorities.list().map((a) => [a.audience, a.capability])),
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ }) : null;
+
+ return {
+ enabled,
+ /** The bearer token for one authority's audience; throws when Network cannot mint it. */
+ get: async (authority) => (client ? client.getToken({ audience: authority.audience }) : null),
+ /** What a caller may show without a token: the audiences it can mint for, and whether it can. */
+ status: () => ({ enabled, client_id: config.oauth.clientId, audiences: authorities.list().map((a) => a.audience) }),
+ invalidate: (ctx) => { if (client) client.invalidate(ctx); },
+ };
+}
+
+module.exports = { createAuthorityTokens };
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+
+/**
+ * OpenVibe.Services configuration. Everything comes from the environment (.env in development,
+ * /etc/openvibe/services.env in production). loadConfig(env) is pure so tests build their own.
+ *
+ * Services is the resource control plane (ADR-048, plan T13): it reads every authority's resource index
+ * and merges it. It owns no other service's data and stores no credential: the authority tokens are
+ * Network client-credentials tokens minted per authority audience at call time (server/authorities/tokens.js)
+ * from OV_OAUTH_CLIENT_ID / OV_OAUTH_CLIENT_SECRET, and the person-facing path passes the caller's own
+ * Network user token through to Network (server/auth.js).
+ */
+require('dotenv').config();
+
+const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
+const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
+const trim = (u) => String(u || '').replace(/\/+$/, '');
+/** A PEM in an environment variable carries its newlines escaped. */
+const pem = (v) => (v ? String(v).replace(/\\n/g, '\n') : null);
+
+/**
+ * SERVICES_<ID>_URL overrides one authority's loopback origin (the id uppercased, '-' -> '_'), e.g.
+ * SERVICES_NETWORK_URL=http://127.0.0.1:4010. The origin otherwise comes from the service manifest
+ * (internalOrigin) of the pinned openvibe-contracts release.
+ */
+function authorityOrigins(env) {
+ const out = {};
+ for (const [k, v] of Object.entries(env)) {
+ const m = /^SERVICES_([A-Z0-9]+(?:_[A-Z0-9]+)*)_URL$/.exec(k);
+ if (m && v) out[m[1].toLowerCase().replace(/_/g, '-')] = trim(v);
+ }
+ return out;
+}
+
+function loadConfig(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 4930); // 4930 is Services'; 4000-4910 and 4920 are taken
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+ return {
+ nodeEnv,
+ isProduction,
+ port,
+ host: env.HOST || '127.0.0.1',
+ baseUrl: trim(env.BASE_URL || (isProduction ? 'https://openvibe.services' : `http://localhost:${port}`)),
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
+
+ // PostgreSQL (ADR-035): DATABASE_URL is the pooled runtime role through PgBouncer (transaction
+ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot.
+ db: {
+ url: env.DATABASE_URL || '',
+ directUrl: env.DATABASE_DIRECT_URL || '',
+ },
+
+ // Identity: the Network signing key verifies every incoming token; NETWORK_URL is the origin the
+ // person-facing path asks about project membership (GET /api/v1/projects/:project, with the
+ // caller's own token — the developer projects API).
+ network: {
+ url: networkUrl,
+ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ publicKey: pem(env.OV_NETWORK_PUBLIC_KEY),
+ },
+ audience: env.SERVICES_AUDIENCE || 'openvibe.services',
+
+ // Services' own Network client (`services`): it mints the per-authority client-credentials
+ // tokens. Unset in development: the fan-out reaches the authorities without a token and an
+ // authority that refuses is reported partial, never fatal (ADR-046 section 6).
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'services',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ },
+
+ // One authority's loopback origin override (see authorityOrigins); the manifest's internalOrigin
+ // is the default.
+ authorityOrigins: authorityOrigins(env),
+
+ // The resource index (plan T13 step 6): the per-authority page size the fan-out asks for, how
+ // many authorities are read at once, and how long one authority may take before it is reported
+ // partial instead of failing the page. `pageLimit` is the caller-facing default and ceiling.
+ index: {
+ pageSize: Math.max(1, int(env.SERVICES_INDEX_PAGE_SIZE, 100)),
+ concurrency: Math.max(1, int(env.SERVICES_INDEX_CONCURRENCY, 4)),
+ timeoutMs: Math.max(1, int(env.SERVICES_INDEX_TIMEOUT_MS, 5000)),
+ defaultLimit: Math.max(1, int(env.SERVICES_INDEX_DEFAULT_LIMIT, 100)),
+ maxLimit: Math.max(1, int(env.SERVICES_INDEX_MAX_LIMIT, 1000)),
+ networkTimeoutMs: Math.max(1, int(env.SERVICES_NETWORK_TIMEOUT_MS, 5000)),
+ },
+
+ // The outbox relay. Services declares no event type yet (plan T13 step 10), so the relay is
+ // idle; EVENT_URL unset (or the OAuth client secret unset) keeps rows in the table.
+ events: {
+ enabled: bool(env.SERVICES_EVENTS_ENABLED, true),
+ url: trim(env.EVENTS_URL || ''),
+ intervalMs: Math.max(250, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
+ },
+ };
+}
+
+module.exports = { loadConfig };
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
+'use strict';
+
+/**
+ * Services' own PostgreSQL database (ADR-035), through openvibe-sdk/db: async, pooled, one dialect.
+ *
+ * The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
+ * (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
+ * transaction mode). Services is new: migrations/0001_initial.sql is written as the final schema, no
+ * window. Nothing here is another service's data (ADR-048): the authority registry, the rebuildable
+ * resource read model, recipes and the control log are Services' own rows.
+ *
+ * openDb(config) the serving handle: DATABASE_URL; in development without it, an
+ * embedded PGlite database in data/pglite (one process)
+ * migrate(config, {serving}) apply migrations/ with the owner role, then close that connection
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+function openDb(config, { registry, log = console } = {}) {
+ if (!config.db.url && !config.isProduction) {
+ log.warn(`[Services] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
+ fs.mkdirSync(DEV_PGLITE, { recursive: true });
+ return createDb({ pglite: DEV_PGLITE, service: 'services', registry, log });
+ }
+ return createDb({ url: config.db.url, service: 'services', registry, log });
+}
+
+/** Apply pending migrations; several processes starting together are serialised by the SDK's lock. */
+async function migrate(config, { serving = null, log = console } = {}) {
+ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });
+ 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: 'services-migrate', max: 1, log });
+ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+}
+
+module.exports = { openDb, migrate, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+
+/**
+ * Services → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
+ *
+ * Services declares no event type yet: ADR-048 puts its control operations and their audit in plan T13
+ * step 10, and no `services.*` event is registered in the pinned openvibe-contracts. The outbox is built
+ * anyway — the table (services_events_outbox), the relay and Services' own OAuth client — so the first
+ * registered type is one string in EVENT_TYPES; until then emit() refuses every envelope and the relay
+ * has nothing to send. A row would be written inside the transaction that makes the change, and the
+ * relay publishes with Services' service token (events.event.publish, audience openvibe.events).
+ */
+const { createServiceOutbox } = require('openvibe-sdk/events');
+
+const TABLE = 'services_events_outbox'; // migrations/0001_initial.sql
+const ACTOR = { type: 'service', id: 'services' };
+const EVENT_TYPES = [];
+
+function createServicesOutbox({ db, config, fetchImpl, now, log = console }) {
+ return createServiceOutbox({
+ db,
+ source: 'services',
+ table: TABLE,
+ eventsUrl: config.events.enabled ? config.events.url : '',
+ networkInternalUrl: config.network.internalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ intervalMs: config.events.intervalMs,
+ log,
+ eventTypes: EVENT_TYPES,
+ ...(fetchImpl ? { fetch: fetchImpl } : {}),
+ ...(now ? { now } : {}),
+ });
+}
+
+module.exports = { createServicesOutbox, TABLE, ACTOR, EVENT_TYPES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * OpenVibe.Services — process entry. `node server/index.js`
+ * Listens on PORT (4930) behind nginx (openvibe.services); see deploy/.
+ *
+ * Boot (ADR-035): apply migrations/ with the owner role (DATABASE_DIRECT_URL, one direct connection,
+ * closed afterwards), then serve on the pooled runtime role (DATABASE_URL). Several processes may start
+ * together: the migration run is serialised by an advisory lock. Nothing starts at module load — the
+ * Network key refresher and the outbox relay start here, and the signal handlers stop them in order.
+ *
+ * The authority registry is built here from the pinned openvibe-contracts service manifests
+ * (server/authorities/index.js) and logged, so the boot line says what this instance will fan out over.
+ * There is no polling loop: the index reads the authorities on each request (plan T13 step 6), and the
+ * rebuildable read model is step 7.
+ */
+const { loadConfig } = require('./config');
+const { openDb, migrate } = require('./db');
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { createRegistry } = require('openvibe-shared/metrics');
+
+async function main() {
+ const config = loadConfig();
+ const registry = createRegistry();
+ const db = openDb(config, { registry });
+ const m = await migrate(config, { serving: db });
+ if (m.held.length) console.warn(`[Services] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
+
+ const app = createApp({ config, db, registry });
+ const { keys, outbox, authorities, tokens } = app.locals;
+ keys.start();
+ outbox.start();
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[Services] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store})`);
+ console.log(`[Services] authorities ${authorities.size()}: ${authorities.ids().join(', ') || 'none'}; authority tokens ${tokens.enabled ? `client ${config.oauth.clientId}` : 'off (OV_OAUTH_CLIENT_SECRET unset: authorities are reported partial)'}; outbox ${outbox.enabled ? `→ ${config.events.url}` : 'idle (no event type declared)'}`);
+ });
+ server.keepAliveTimeout = 65_000;
+ server.headersTimeout = 66_000;
+ server.requestTimeout = 40_000;
+
+ // systemd sends SIGTERM (SIGINT by hand); openvibe-sdk/service's gracefulStop takes the signal, runs
+ // the stop steps in order (nothing new starts), drains the HTTP server, then the close steps and
+ // exits. Services' manifest declares no lifecycle.shutdown deadline, so the kit's default fits.
+ const { stop: shutdown } = gracefulStop({
+ name: 'Services',
+ server,
+ drainMs: 4000,
+ deadlineMs: 5000,
+ deadlineExitCode: 0,
+ stop: [
+ () => keys.stop(),
+ () => outbox.stop(),
+ ],
+ close: [
+ () => db.close().catch(() => {}),
+ ],
+ });
+ return { app, server, shutdown };
+}
+
+if (require.main === module) {
+ main().catch((e) => {
+ console.error(`[Services] could not start: ${e.message}`);
+ process.exit(1);
+ });
+}
+
+module.exports = { main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+
+/**
+ * Services' side of OpenVibe.Network: the signing key every incoming token is verified against, and the
+ * project-membership question the person-facing index path asks.
+ *
+ * createKeyProvider(config) Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins
+ * it, else the JWKS (GET {internalUrl}/api/.well-known/jwks) refreshed at boot
+ * and every six hours; a JWKS outage keeps the last key and never stops the
+ * process.
+ * createUserAuth(config, keys) offline verification of a person's Network user token (openvibe-sdk/auth:
+ * RS256, issuer, expiry; a realtime/stream ticket is never a session).
+ * projectMember(config, token, project) does the person this token belongs to own or belong to
+ * `project`? Asked of Network's developer projects API
+ * (GET /api/v1/projects/:project, viewer+) with the caller's OWN token —
+ * Services never holds a project's membership itself (ADR-048: Network owns
+ * projects; Services only shows and calls it).
+ */
+const crypto = require('crypto');
+const { verifyUserToken } = require('openvibe-sdk/auth');
+
+/** Network's RS256 public key: OV_NETWORK_PUBLIC_KEY when the operator pins it, else the JWKS at boot. */
+function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
+ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
+ let timer = null;
+ async function load() {
+ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
+ try {
+ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`JWKS ${res.status}`);
+ const body = await res.json();
+ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
+ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
+ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
+ else throw new Error('JWKS contained no keys');
+ return pem;
+ } catch (e) {
+ log.warn(`[Services] Network key not loaded from ${url}: ${e.message}`);
+ return null;
+ }
+ }
+ function start() {
+ if (config.network.publicKey || timer) return;
+ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
+ retry();
+ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
+ timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+ return { get: () => pem, load, start, stop };
+}
+
+/**
+ * Offline verification of a Network user token (openvibe-sdk/auth: RS256, issuer, expiry, and a typed
+ * token — a realtime or stream ticket — is never a session). Returns the claims, or null.
+ */
+function createUserAuth(config, keys) {
+ async function verify(token) {
+ if (!token) return null;
+ const publicKey = keys.get();
+ if (!publicKey) return null;
+ try {
+ const claims = await verifyUserToken(token, { publicKey, issuer: config.network.issuer });
+ return claims && typeof claims === 'object' ? claims : null;
+ } catch { return null; }
+ }
+ return { verify };
+}
+
+/**
+ * Does the person who presented `token` own or belong to `project`?
+ * Network's developer projects API answers 200 to a viewer-or-higher member and 404 to everyone else
+ * (existence is not disclosed), so this returns 'yes' | 'no' | 'unavailable' and the caller answers 404
+ * or 503 — never a 403 that would confirm the project exists.
+ */
+function createMembership(config, { fetchImpl = globalThis.fetch } = {}) {
+ async function projectMember(token, projectId) {
+ const url = `${config.network.url}/api/v1/projects/${encodeURIComponent(projectId)}`;
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
+ signal: AbortSignal.timeout(config.index.networkTimeoutMs),
+ });
+ } catch {
+ return 'unavailable';
+ }
+ try { await res.body?.cancel(); } catch { /* not needed */ }
+ if (res.status === 200) return 'yes';
+ if (res.status === 404) return 'no';
+ // 401/403 from Network means this token is not a Network session (or is revoked): it is not a
+ // member of anything Services can vouch for. 5xx and 429 are Network being unable to answer.
+ if (res.status === 401 || res.status === 403) return 'no';
+ return 'unavailable';
+ }
+ return { projectMember };
+}
+
+module.exports = { createKeyProvider, createUserAuth, createMembership };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+
+/**
+ * Truthful readiness (GET /api/ready) and the Services gauges (GET /metrics, loopback only).
+ *
+ * db required a real round trip through the pool (db.ready()) — migrations/ runs at boot
+ * network_jwks required the Network signing key has loaded; without it no token can be verified and
+ * every /api/v1 route answers 503 (server/auth.js), so the service cannot
+ * serve its one capability
+ *
+ * The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades
+ * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page,
+ * named in X-OpenVibe-Partial-Authorities, not a red readiness — /api/v1/authorities lists the registry
+ * as it stands.
+ *
+ * Gauges: the authorities the registry reads. Counts only, never a token, never a resource.
+ */
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServicesReadiness({ db, keys, authorities, release = null }) {
+ return createReadiness({
+ service: 'services',
+ release,
+ checks: [
+ { name: 'db', required: true, check: () => db.ready() },
+ { name: 'network_jwks', required: true, check: () => (keys.get() ? true : 'Network signing key not loaded yet: no token can be verified') },
+ ],
+ details: async () => ({ authorities: { count: authorities.size(), ids: authorities.ids() } }),
+ });
+}
+
+/** The Services gauges on the openvibe-shared/metrics registry. */
+function registerServicesGauges(registry, { authorities }) {
+ registry.gauge({
+ name: 'services_authorities',
+ help: 'Authorities in the registry (services whose manifest lists an active <id>.resource.read)',
+ collect: () => authorities.size(),
+ });
+ return { authorities: () => authorities.size() };
+}
+
+module.exports = { createServicesReadiness, registerServicesGauges };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+
+/**
+ * The Services resource index (ADR-048, plan T13 step 6):
+ *
+ * GET /api/v1/resources?project=&kind=&service=&cursor=&limit= every authority's page, merged
+ * GET /api/v1/resources/:ovrn one resource, asked of its authority
+ *
+ * The fan-out, each authority's opaque-cursor walk and the merge are openvibe-sdk/resources'
+ * `createResourceIndex` — Services does not reimplement any of it. Services adds:
+ *
+ * - the authority set (one adapter each, its own client-credentials token; server/authorities/),
+ * - a deterministic total order over the merged resources (service, ovrn, id) and an opaque keyset
+ * cursor into it, so a page walk never repeats or skips a resource,
+ * - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a
+ * scoped read (a person's, above all) must never show a row outside its scope even if an authority
+ * ignores a query parameter,
+ * - and the partial answer. common.resource-list-result@1 has no field for the authorities that
+ * could not be read (its only fields are `resources` and `next_cursor`, additionalProperties:
+ * false), so a partial page names them in the X-OpenVibe-Partial-Authorities response header —
+ * their rows are omitted, the rest of the page is served (ADR-046 section 6: an authority, or
+ * Network, being down degrades the index; it never stops it).
+ *
+ * One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser
+ * (contracts.resources.parse), resolves the owning service and asks that authority directly.
+ */
+const contracts = require('openvibe-contracts');
+const { createResourceIndex } = require('openvibe-sdk/resources');
+const { createAuthorityAdapter, createAuthorityFetcher } = require('./authorities/adapter');
+const { ServiceError, parseOvrn } = require('./util');
+
+const CURSOR_V = 'v1';
+const SEP = '\u0000';
+
+/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */
+const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;
+const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
+
+const encodeCursor = (key) => Buffer.from(JSON.stringify([CURSOR_V, key])).toString('base64url');
+function decodeCursor(raw) {
+ let v;
+ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
+ return Array.isArray(v) && v.length === 2 && v[0] === CURSOR_V && typeof v[1] === 'string' ? v[1] : null;
+}
+
+function createResourceIndexService({ config, authorities, tokens, fetchImpl = globalThis.fetch, log = console }) {
+ const adapters = authorities.list().map((a) => createAuthorityAdapter(a, { tokens, fetchImpl, timeoutMs: config.index.timeoutMs }));
+ const adapterById = new Map(adapters.map((a) => [a.authority.id, a]));
+ const idByOrigin = new Map(adapters.map((a) => [a.origin, a.authority.id]));
+ const indexes = new Map();
+ let lastPartial = '';
+
+ /** One SDK index per authority set (all of them, or the one ?service= names), created once. */
+ function indexFor(set) {
+ const key = set.map((a) => a.authority.id).join(',');
+ let index = indexes.get(key);
+ if (!index) {
+ index = createResourceIndex({
+ authorities: set.map((a) => a.origin),
+ fetch: createAuthorityFetcher(set),
+ pageLimit: config.index.pageSize,
+ concurrency: config.index.concurrency,
+ timeoutMs: config.index.timeoutMs,
+ });
+ indexes.set(key, index);
+ }
+ return index;
+ }
+
+ /**
+ * One merged page. `{ resources, next_cursor, partial }` — `partial` lists the authority ids whose
+ * rows this page could not include (their cursor walk failed, timed out or was refused).
+ */
+ async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {
+ const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;
+ if (service && !set.length) throw new ServiceError(400, 'resources.unknown_service', `${service} is not an authority Services reads`);
+ if (!set.length) return { resources: [], next_cursor: null, partial: [] };
+ const { resources, stale } = await indexFor(set).list({ project, kind, limit: config.index.pageSize });
+ let merged = resources;
+ // The authority filters on its own; this keeps a scoped read scoped even if one does not
+ // (project_id is optional on common.resource-summary@1, so a row without one fails a project filter).
+ if (service) merged = merged.filter((r) => r.service === service);
+ if (kind) merged = merged.filter((r) => r.kind === kind);
+ if (project) merged = merged.filter((r) => r.project_id === project);
+ merged = merged.slice().sort((a, b) => compare(sortKeyOf(a), sortKeyOf(b)));
+ const after = cursor ? decodeCursor(cursor) : null;
+ if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
+ const rest = after ? merged.filter((r) => compare(sortKeyOf(r), after) > 0) : merged;
+ const page = rest.slice(0, limit);
+ const partial = stale.map((s) => idByOrigin.get(s.authority) || s.authority);
+ // One line when the set of silent authorities changes — an operator wants to know, a log wants no
+ // flood; the caller reads the same fact from the header.
+ const signature = partial.join(',');
+ if (signature !== lastPartial) {
+ if (signature) log.warn(`[Services] index partial: ${signature} did not answer`);
+ lastPartial = signature;
+ }
+ return {
+ resources: page,
+ next_cursor: rest.length > limit ? encodeCursor(sortKeyOf(page[page.length - 1])) : null,
+ partial,
+ };
+ }
+
+ /**
+ * The OVRN's four segments and the authority that owns it, or ServiceError: 400 resources.bad_name
+ * (not an OVRN at all) / 400 resources.unknown_service (no authority of that id). A route authorizes
+ * with the returned project BEFORE anything is asked of the authority.
+ */
+ function resolve(ovrn) {
+ const parsed = parseOvrn(ovrn); // contracts.resources.parse, the one parser
+ const adapter = adapterById.get(parsed.service);
+ if (!adapter) throw new ServiceError(400, 'resources.unknown_service', `${parsed.service} is not an authority Services reads`);
+ return { ...parsed, adapter };
+ }
+
+ /** The summary whose OVRN this is, from the authority that owns it. Throws ServiceError on refusal. */
+ async function get(ovrn) {
+ const parsed = resolve(ovrn);
+ const { status, body } = await parsed.adapter.get(ovrn);
+ if (status === 404) throw new ServiceError(404, 'resources.not_found', `no resource named ${ovrn}`);
+ if (status !== 200) throw new ServiceError(502, 'resources.authority_error', `${parsed.service} answered HTTP ${status} for ${ovrn}`);
+ const check = contracts.validate('common.resource-summary@1', body);
+ if (!check.valid) throw new ServiceError(502, 'resources.authority_bad_response', `${parsed.service} answered something that is not a common.resource-summary@1`);
+ if (body.ovrn !== ovrn && contracts.resources.nameOf(body) !== ovrn) {
+ throw new ServiceError(502, 'resources.authority_bad_response', `${parsed.service} answered a resource that is not ${ovrn}`);
+ }
+ return body;
+ }
+
+ /** Drop the cached SDK indexes (an authority set changed at runtime — boot-time registry today). */
+ function reset() { indexes.clear(); }
+
+ return { list, get, resolve, reset, adapters };
+}
+
+module.exports = { createResourceIndexService, encodeCursor, decodeCursor, sortKeyOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Small shared pieces: the error a route answers with, and the OVRN helpers every resource route uses. */
+const contracts = require('openvibe-contracts');
+
+/**
+ * A refusal this service decided: `status` + a stable problem code + a public detail. The error handler
+ * in server/app.js turns it into application/problem+json; anything else is a 500.
+ */
+class ServiceError extends Error {
+ constructor(status, code, detail, extra) {
+ super(detail || code);
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.extra = extra;
+ }
+}
+
+function fail(status, code, detail, extra) { throw new ServiceError(status, code, detail, extra); }
+
+/**
+ * An OVRN parsed with openvibe-contracts' one parser (contracts.resources.parse — Services never splits
+ * on ':'), or a 400 resources.bad_name. `:ovrn` routes use this before anything else.
+ */
+function parseOvrn(name) {
+ const parsed = contracts.resources.parse(name);
+ if (!parsed) fail(400, 'resources.bad_name', `${name} is not a resource name (ovrn:<service>:<project_id>:<type>/<id>)`);
+ return parsed;
+}
+
+module.exports = { ServiceError, fail, parseOvrn };
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -0,0 +1,80 @@
+'use strict';
+/**
+ * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.108.0): the registry is
+ * derived from the released service manifests — every non-placeholder service whose manifest lists an
+ * active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest
+ * loopback origins and their audiences; Services itself, placeholders and services without the
+ * capability are not authorities.
+ *
+ * node test/authorities.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { loadConfig } = require('../server/config');
+const { createAuthorities } = require('../server/authorities');
+const { check, done } = require('./helpers/app');
+
+const quiet = { warn() {}, log() {}, error() {} };
+
+async function main() {
+ await check('the pin lists exactly five active <id>.resource.read authorities', () => {
+ const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();
+ assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read']);
+ for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
+ });
+
+ await check('the registry is built from those manifests, with the manifest origins and audiences', () => {
+ const registry = createAuthorities(loadConfig({}), { log: quiet });
+ assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);
+ const expected = {
+ network: 'http://127.0.0.1:4000',
+ media: 'http://127.0.0.1:4100',
+ events: 'http://127.0.0.1:4300',
+ codes: 'http://127.0.0.1:4900',
+ host: 'http://127.0.0.1:4910',
+ };
+ for (const [id, origin] of Object.entries(expected)) {
+ const a = registry.get(id);
+ assert.ok(a, `${id} is an authority`);
+ assert.strictEqual(a.internalOrigin, origin);
+ assert.strictEqual(a.audience, `openvibe.${id}`);
+ assert.strictEqual(a.capability, `${id}.resource.read`);
+ assert.strictEqual(a.name, contracts.services.get(id).name);
+ assert.strictEqual(a.manifestVersion, contracts.services.get(id).version);
+ assert.strictEqual(a.publicOrigin, contracts.services.get(id).publicOrigin);
+ }
+ assert.strictEqual(registry.get('nope'), null);
+ assert.strictEqual(registry.size(), 5);
+ });
+
+ await check('Services, placeholders and services without the capability are not authorities', () => {
+ const registry = createAuthorities(loadConfig({}), { log: quiet });
+ for (const id of ['services', 'run', 'actor', 'watch', 'actor-console', 'sites']) {
+ if (!contracts.services.get(id)) continue;
+ assert.strictEqual(registry.get(id), null, `${id} must not be an authority`);
+ }
+ // Services' own manifest is a placeholder today; even a live one would never index itself.
+ assert.ok(!registry.ids().includes('services'));
+ });
+
+ await check('SERVICES_<ID>_URL overrides one origin, and never invents an authority', () => {
+ const registry = createAuthorities(loadConfig({ SERVICES_MEDIA_URL: 'http://127.0.0.1:4999', SERVICES_RUN_URL: 'http://127.0.0.1:4888' }), { log: quiet });
+ assert.strictEqual(registry.get('media').internalOrigin, 'http://127.0.0.1:4999');
+ assert.strictEqual(registry.get('run'), null, 'an override for a service without the capability changes nothing');
+ assert.deepStrictEqual(registry.ids(), ['codes', 'events', 'host', 'media', 'network']);
+ });
+
+ await check('every registry entry satisfies the rule the registry claims', () => {
+ for (const a of createAuthorities(loadConfig({}), { log: quiet }).list()) {
+ const service = contracts.services.get(a.id);
+ assert.ok(service.capabilities.includes(a.capability), `${a.id} lists its capability`);
+ assert.strictEqual(contracts.capabilities.get(a.capability).status, 'active');
+ assert.ok(service.internalOrigin, `${a.id} has a loopback origin`);
+ assert.ok(!['retired', 'placeholder'].includes(service.status), `${a.id} is served`);
+ }
+ });
+
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+/**
+ * server/config.js: everything comes from the environment, loadConfig(env) is pure, and the defaults are
+ * Services' own (port 4930, audience openvibe.services, the index's page size, concurrency and timeout).
+ *
+ * node test/config.test.js
+ */
+const assert = require('assert');
+const { loadConfig } = require('../server/config');
+const { check, done } = require('./helpers/app');
+
+async function main() {
+ await check('defaults: port 4930, 127.0.0.1, audience openvibe.services, development', () => {
+ const c = loadConfig({});
+ assert.strictEqual(c.port, 4930);
+ assert.strictEqual(c.host, '127.0.0.1');
+ assert.strictEqual(c.nodeEnv, 'development');
+ assert.strictEqual(c.isProduction, false);
+ assert.strictEqual(c.baseUrl, 'http://localhost:4930');
+ assert.strictEqual(c.audience, 'openvibe.services');
+ assert.strictEqual(c.network.url, 'https://openvibe.network');
+ assert.strictEqual(c.network.internalUrl, 'http://127.0.0.1:4000');
+ assert.strictEqual(c.network.issuer, 'https://openvibe.network');
+ assert.strictEqual(c.oauth.clientId, 'services');
+ assert.strictEqual(c.oauth.clientSecret, '');
+ assert.deepStrictEqual(c.index, { pageSize: 100, concurrency: 4, timeoutMs: 5000, defaultLimit: 100, maxLimit: 1000, networkTimeoutMs: 5000 });
+ assert.deepStrictEqual(c.authorityOrigins, {});
+ assert.strictEqual(c.events.enabled, true);
+ assert.strictEqual(c.events.url, '');
+ assert.strictEqual(c.db.url, '');
+ });
+
+ await check('the environment overrides every default', () => {
+ const c = loadConfig({
+ NODE_ENV: 'production', PORT: '5000', HOST: '0.0.0.0', BASE_URL: 'https://openvibe.services/',
+ DATABASE_URL: 'postgres://runtime', DATABASE_DIRECT_URL: 'postgres://owner',
+ OV_NETWORK_URL: 'https://network.example/', OV_NETWORK_INTERNAL_URL: 'http://127.0.0.1:4001/',
+ OV_NETWORK_ISSUER: 'https://issuer.example', OV_NETWORK_PUBLIC_KEY: '-----BEGIN\\nKEY-----',
+ OV_OAUTH_CLIENT_ID: 'svc', OV_OAUTH_CLIENT_SECRET: 'secret',
+ SERVICES_INDEX_PAGE_SIZE: '2', SERVICES_INDEX_CONCURRENCY: '1', SERVICES_INDEX_TIMEOUT_MS: '250',
+ SERVICES_INDEX_DEFAULT_LIMIT: '10', SERVICES_INDEX_MAX_LIMIT: '20', SERVICES_NETWORK_TIMEOUT_MS: '750',
+ SERVICES_MEDIA_URL: 'http://127.0.0.1:4999/', SERVICES_NETWORK_URL: 'http://127.0.0.1:4010',
+ EVENTS_URL: 'http://127.0.0.1:4300/', EVENTS_RELAY_INTERVAL_MS: '500', SERVICES_EVENTS_ENABLED: '0',
+ });
+ assert.strictEqual(c.isProduction, true);
+ assert.strictEqual(c.port, 5000);
+ assert.strictEqual(c.baseUrl, 'https://openvibe.services'); // a trailing slash is trimmed
+ assert.strictEqual(c.db.url, 'postgres://runtime');
+ assert.strictEqual(c.network.url, 'https://network.example');
+ assert.strictEqual(c.network.internalUrl, 'http://127.0.0.1:4001');
+ assert.strictEqual(c.network.issuer, 'https://issuer.example');
+ assert.strictEqual(c.network.publicKey, '-----BEGIN\nKEY-----'); // \n escaped in the env
+ assert.strictEqual(c.oauth.clientSecret, 'secret');
+ assert.deepStrictEqual(c.index, { pageSize: 2, concurrency: 1, timeoutMs: 250, defaultLimit: 10, maxLimit: 20, networkTimeoutMs: 750 });
+ assert.deepStrictEqual(c.authorityOrigins, { media: 'http://127.0.0.1:4999', network: 'http://127.0.0.1:4010' });
+ assert.strictEqual(c.events.enabled, false);
+ assert.strictEqual(c.events.url, 'http://127.0.0.1:4300');
+ assert.strictEqual(c.events.intervalMs, 500);
+ });
+
+ await check('a production baseUrl defaults to the domain, and an unnamed authority is not invented', () => {
+ const c = loadConfig({ NODE_ENV: 'production' });
+ assert.strictEqual(c.baseUrl, 'https://openvibe.services');
+ assert.deepStrictEqual(c.authorityOrigins, {});
+ const dev = loadConfig({ SERVICES_MEDIA_URL: '' });
+ assert.deepStrictEqual(dev.authorityOrigins, {}, 'an empty override is not an origin');
+ });
+
+ await check('loadConfig is pure: it never writes what it read', () => {
+ const env = { PORT: '7000' };
+ loadConfig(env);
+ assert.deepStrictEqual(env, { PORT: '7000' });
+ });
+
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+/**
+ * migrations/0001_initial.sql on a real (PGlite) PostgreSQL: the seven tables Services owns, the
+ * idempotency key of services_control_log, the services_resources foreign key to the authority registry,
+ * and the outbox table's shape.
+ *
+ * node test/db.test.js
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { testDb } = require('./helpers/db');
+const { check, done } = require('./helpers/app');
+
+async function main() {
+ const store = await testDb();
+ const db = store.db;
+ try {
+ await check('every table of 0001_initial.sql exists', async () => {
+ const rows = await db.many("SELECT tablename FROM pg_tables WHERE schemaname = current_schema() ORDER BY tablename");
+ const names = rows.map((r) => r.tablename);
+ for (const t of ['services_authorities', 'services_resources', 'services_catalog_entries', 'services_recipes', 'services_recipe_runs', 'services_control_log', 'services_events_outbox']) {
+ assert.ok(names.includes(t), `${t} exists (have ${names.join(', ')})`);
+ }
+ });
+
+ await check('services_authorities takes the registry row and refuses a bad trust or status', async () => {
+ await db.query(`INSERT INTO services_authorities (id, name, internal_origin, manifest_version) VALUES ('media', 'OpenVibe.Media', 'http://127.0.0.1:4100', '0.2.0')`);
+ const row = await db.one('SELECT id, trust, status FROM services_authorities WHERE id = $1', ['media']);
+ assert.deepStrictEqual(row, { id: 'media', trust: 'first-party', status: 'unknown' });
+ await assert.rejects(() => db.query(`INSERT INTO services_authorities (id, name, internal_origin, manifest_version, trust) VALUES ('x', 'x', 'http://127.0.0.1:1', '0', 'friend')`));
+ });
+
+ await check('services_resources references the authority registry: no resource of no service', async () => {
+ const project = ids.newId('project');
+ const object = ids.newId('media');
+ await assert.rejects(() => db.query(`INSERT INTO services_resources (ovrn, service, project_id, kind, resource_id, state)
+ VALUES ($1, 'ghost', $2, 'ghost.thing', $3, 'active')`, [`ovrn:ghost:${project}:thing/${object}`, project, object]));
+ await db.query(`INSERT INTO services_resources (ovrn, service, project_id, kind, resource_id, state)
+ VALUES ($1, 'media', $2, 'media.object', $3, 'active')`, [`ovrn:media:${project}:object/${object}`, project, object]);
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_resources'), 1);
+ });
+
+ await check('services_control_log holds one row per idempotency key', async () => {
+ const insert = (id, key) => db.query(`INSERT INTO services_control_log (id, action, actor_subject, idempotency_key, request, status)
+ VALUES ($1, 'start', $2, $3, '{}'::jsonb, 'pending')`, [id, `user:${ids.newId('user')}`, key]);
+ await insert(`ctl_${ids.ulid()}`, 'key-1');
+ await assert.rejects(() => insert(`ctl_${ids.ulid()}`, 'key-1')); // the same key, another row: refused
+ await insert(`ctl_${ids.ulid()}`, 'key-2');
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_control_log'), 2);
+ });
+
+ await check('services_events_outbox is the openvibe-sdk outbox shape, ready for the first event', async () => {
+ const cols = (await db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'services_events_outbox'")).map((r) => r.column_name);
+ for (const c of ['id', 'event_id', 'envelope', 'traceparent', 'created_at', 'attempts', 'next_attempt_at', 'sent_at', 'seq', 'rejected_at', 'last_error']) {
+ assert.ok(cols.includes(c), `outbox column ${c}`);
+ }
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM services_events_outbox'), 0);
+ });
+ } finally {
+ await store.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+/**
+ * Boots Services against the stub Network and the stub authorities a test names, on a random port, with a
+ * migrated database of its own (helpers/db.js: PGlite, or the containers with SERVICES_TEST_STORE=pg).
+ * The key refresher and the outbox relay are NOT started (server/index.js starts them in production);
+ * the JWKS is loaded once, so the app verifies tokens the way it does in production — through the stub
+ * Network's own key, never a bypass.
+ *
+ * const t = await boot({ authorities: [{ id: 'alpha', resources: [...] }, …] });
+ * t.call(method, path, { token | cap, sub, project, user, headers }) an HTTP call with a service or
+ * user token the stub Network signed
+ * t.network the stub Network (mints, signService, signUser, addMember)
+ * t.authority('alpha') the stub authority: .url, .calls, .resources
+ * t.base, t.db the URL and the migrated database
+ *
+ * The default page size is deliberately small (SERVICES_INDEX_PAGE_SIZE=2), so a fan-out walks more than
+ * one page per authority unless a test says otherwise.
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { testDb } = require('./db');
+const { startNetwork, startAuthority, registryOf } = require('./stubs');
+
+/** A fresh prj_ id (the tenancy boundary of every fixture). */
+const newProject = () => ids.newId('project');
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'services-test-'));
+ const env = {
+ NODE_ENV: 'test',
+ BASE_URL: 'http://services.test',
+ OV_NETWORK_URL: network.url,
+ OV_NETWORK_INTERNAL_URL: network.url,
+ OV_NETWORK_ISSUER: network.issuer,
+ OV_OAUTH_CLIENT_ID: 'services',
+ OV_OAUTH_CLIENT_SECRET: 'services-secret',
+ SERVICES_INDEX_TIMEOUT_MS: '400',
+ SERVICES_INDEX_PAGE_SIZE: '2', // small: the tests exercise the cursor walk
+ SERVICES_INDEX_CONCURRENCY: '2',
+ ...(opts.env || {}),
+ };
+ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
+ const { loadConfig } = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const config = loadConfig(env);
+ const logs = [];
+ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
+
+ const stubs = [];
+ for (const a of opts.authorities || []) stubs.push(await startAuthority(a));
+ const authorities = opts.registry || registryOf(stubs);
+ const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
+ const app = createApp({ config, db: store.db, authorities, log, ...(opts.appOpts || {}) });
+ await app.locals.keys.load();
+
+ const server = await new Promise((resolve) => {
+ const s = http.createServer(app);
+ s.listen(0, '127.0.0.1', () => resolve(s));
+ });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ async function call(method, p, { body, token, cap = ['services.resource.read'], sub = 'svc:console', project, user, headers = {} } = {}) {
+ const h = { ...headers };
+ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ cap, sub, ...(project ? { project_id: project } : {}) }))}`;
+ if (body !== undefined) h['Content-Type'] = 'application/json';
+ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
+ return { status: res.status, headers: res.headers, json, text };
+ }
+
+ return {
+ app, base, call, db: store.db, store, config, network, logs, dir,
+ authority: (id) => stubs.find((s) => s.id === id) || null,
+ authorities: stubs,
+ wait: (ms) => new Promise((r) => setTimeout(r, ms)),
+ close: async () => {
+ server.closeAllConnections();
+ await new Promise((r) => server.close(r));
+ await app.locals.outbox.stop();
+ for (const s of stubs) await s.close();
+ await network.close();
+ await store.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ },
+ };
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); }
+ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done, newProject };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -0,0 +1,74 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035).
+ *
+ * store 'pglite' (the default): real PostgreSQL in-process, migrated.
+ * store 'pg': the production-shaped containers (openvibe-sdk scripts/test-services.sh up), when
+ * OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL are set. The run gets its own roles and schema.
+ *
+ * TIPS-style: SERVICES_TEST_STORE=pg runs every test file on the containers instead of PGlite.
+ */
+const crypto = require('crypto');
+const { createDb } = require('openvibe-sdk/db');
+const { MIGRATIONS } = require('../../server/db');
+
+const quiet = { log() {}, warn() {}, error: (...a) => console.error(...a) };
+const pgAvailable = () => !!(process.env.OV_TEST_PG_URL && process.env.OV_TEST_PG_DIRECT_URL);
+
+async function testDb({ store = process.env.SERVICES_TEST_STORE || 'pglite', max = 4 } = {}) {
+ if (store !== 'pg') {
+ const db = createDb({ pglite: true, service: 'services-test', log: quiet });
+ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
+ return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
+ }
+ if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
+ const name = `services_t${process.pid}_${crypto.randomBytes(4).toString('hex')}`;
+ const owner = `${name}_owner`;
+ const pw = crypto.randomBytes(16).toString('hex');
+ const su = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'services-test-admin', max: 1, log: quiet });
+ const database = await su.value('SELECT current_database()');
+ for (const stmt of [
+ `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,
+ `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,
+ `GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${name}`,
+ `CREATE SCHEMA ${name} AUTHORIZATION ${owner}`,
+ `ALTER ROLE ${owner} SET search_path = ${name}`,
+ `ALTER ROLE ${name} SET search_path = ${name}`,
+ `ALTER ROLE ${name} SET statement_timeout = '15s'`,
+ `ALTER ROLE ${name} SET lock_timeout = '5s'`,
+ `GRANT USAGE ON SCHEMA ${name} TO ${name}`,
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,
+ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,
+ ]) await su.query(stmt);
+ const as = (url, user) => { const u = new URL(url); u.username = user; u.password = pw; return u.toString(); };
+ async function drop() {
+ try {
+ await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
+ for (let i = 0; i < 100; i++) {
+ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
+ if (!Number(left.rows[0].n)) break;
+ await new Promise((ok) => setTimeout(ok, 50));
+ }
+ const ddl = async (stmt) => {
+ for (let i = 0; ; i++) {
+ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }
+ }
+ };
+ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);
+ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }
+ } finally { await su.close(); }
+ }
+ let db;
+ try {
+ const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'services-test-migrate', max: 1, log: quiet });
+ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }
+ db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'services-test', max, log: quiet });
+ } catch (e) { await drop().catch(() => {}); throw e; }
+ return {
+ db, store: 'postgresql', schema: name,
+ open: (o = {}) => createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'services-test', max, log: quiet, ...o }),
+ async close() { await db.close().catch(() => {}); await drop(); },
+ };
+}
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+/**
+ * The two in-process stubs every Services test runs against (no real Network, no real authority):
+ *
+ * startNetwork() OpenVibe.Network: the JWKS the app's key provider loads, the OAuth token endpoint
+ * (grant_type=client_credentials) with EVERY mint recorded — so a test can prove
+ * Services mints one token per authority audience and reuses it — and
+ * GET /api/v1/projects/:project, the membership answer the person-facing path asks
+ * (200 for a member, 404 like Network for everyone else).
+ *
+ * startAuthority() one authority's resource API: GET /api/v1/resources?project=&kind=&cursor=&limit=
+ * (common.resource-list-result@1, paged) and GET /api/v1/resources/:ovrn
+ * (common.resource-summary@1 or 404). `hang`, `status` and `latencyMs` make it slow,
+ * refusing or unreachable, and `calls` records every request it received (path,
+ * query, Authorization) so a test can see which authority was asked what.
+ *
+ * Fixtures are built with the one formatter (contracts.resources.format): every summary carries a valid
+ * OVRN, so what the stubs answer is what a real authority would.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, ids, resources: resourceNames } = require('openvibe-contracts');
+
+const listen = (server) => new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
+const b64url = (s) => Buffer.from(JSON.stringify(s)).toString('base64url');
+const unb64url = (s) => { try { return JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8')); } catch { return null; } };
+const problem = (res, status, code, detail) => {
+ res.writeHead(status, { 'Content-Type': 'application/problem+json' });
+ res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail }));
+};
+const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => resolve(b)); });
+
+/** A common.resource-summary@1 with a valid OVRN (built by the one formatter). `kind` is `<service>.<type>`. */
+function summary({ service, kind, project, id = ids.newId('watch'), state = 'active', name, created_at }) {
+ return {
+ id, kind, service, project_id: project, state,
+ name: name || `${kind} ${id.slice(-6)}`,
+ created_at: created_at || new Date(Date.parse('2026-01-01T00:00:00Z')).toISOString(),
+ ovrn: resourceNames.format({ service, project_id: project, type: kind.split('.')[1], id }),
+ };
+}
+
+/**
+ * OpenVibe.Network as the two routes Services uses, plus the project-membership answer.
+ * `failTokens` makes the token endpoint refuse (5xx): the JWKS still loads and incoming tokens still
+ * verify, so a test can watch the authority tokens fail alone.
+ */
+async function startNetwork({ failTokens = false } = {}) {
+ 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 issuer = 'http://network.test';
+ const mints = []; // every client_credentials grant: { audience, scope, client_id }
+ const members = new Map(); // project -> Set of subject ids
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://network.test');
+ if (url.pathname === '/api/.well-known/jwks') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ keys: [{ kty: 'RSA', kid: 'n1', ...publicKey.export({ format: 'jwk' }) }], public_key: publicPem }));
+ }
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const form = new URLSearchParams(await readBody(req));
+ if (failTokens) return problem(res, 503, 'oauth.unavailable', 'the token endpoint is down');
+ const audience = form.get('audience');
+ const scope = form.get('scope') || '';
+ mints.push({ audience, scope, client_id: form.get('client_id') });
+ if (form.get('grant_type') !== 'client_credentials' || form.get('client_secret') !== 'services-secret') {
+ return problem(res, 401, 'oauth.invalid_client', 'not Services');
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ access_token: `svc-token:${audience}:${mints.filter((m) => m.audience === audience).length}`, token_type: 'Bearer', expires_in: 300, scope }));
+ }
+ if (url.pathname.startsWith('/api/v1/projects/') && req.method === 'GET') {
+ const project = decodeURIComponent(url.pathname.slice('/api/v1/projects/'.length));
+ const payload = unb64url(String(req.headers.authorization || '').replace(/^Bearer /, '').split('.')[1]);
+ const subject = payload && payload.subject_id;
+ const mine = members.get(project);
+ if (subject && mine && mine.has(subject)) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ project: { id: project, name: 'stub', owner: { type: 'user', id: subject } } }));
+ }
+ return problem(res, 404, 'project.not_found', `no project ${project} for this caller`);
+ }
+ return problem(res, 404, 'route.not_found', url.pathname);
+ });
+ const url = await listen(server);
+ return {
+ url, issuer, publicPem, privatePem, mints, members,
+ /** A Network service token (identity.service-token-claims@1) for audience openvibe.services. */
+ signService({ sub = 'svc:console', aud = ['openvibe.services'], cap = ['services.resource.read'], project_id, actor_type = 'service', env, on_behalf_of, expSec = 300 } = {}) {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({
+ iss: issuer, sub, actor_type, aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex'),
+ ...(project_id ? { project_id } : {}), ...(env ? { env } : {}), ...(on_behalf_of ? { on_behalf_of } : {}),
+ }, privatePem);
+ },
+ /** A person's Network user token (a user token names no capability and no project). */
+ signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {
+ return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, username, display_name: username, role }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
+ },
+ /** Add `subject` as a member of `project` (what Network's developer API answers 200 for). */
+ addMember(project, subject) { if (!members.has(project)) members.set(project, new Set()); members.get(project).add(subject); },
+ mintsFor: (audience) => mints.filter((m) => m.audience === audience),
+ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),
+ };
+}
+
+/**
+ * One authority's resource index (server/authorities/adapter.js talks to exactly these two routes).
+ * `ignoreProject` makes it answer every project's rows whatever `?project=` says — a non-conforming
+ * authority, so a test can prove Services still keeps a scoped read scoped.
+ */
+async function startAuthority({ id, resources = [], hang = false, status = null, latencyMs = 0, ignoreProject = false } = {}) {
+ const calls = [];
+ const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://authority.test');
+ calls.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), authorization: req.headers.authorization || null });
+ if (hang) return; // never answers: the caller's timeout must decide
+ if (status) return problem(res, status, 'authority.refused', `${id} refuses`);
+ if (latencyMs) await new Promise((r) => setTimeout(r, latencyMs));
+ if (req.method === 'GET' && url.pathname === '/api/v1/resources') {
+ const project = url.searchParams.get('project');
+ const kind = url.searchParams.get('kind');
+ const limit = Number(url.searchParams.get('limit') || 100);
+ const offset = Number(unb64url(url.searchParams.get('cursor')) || 0);
+ const scoped = ignoreProject ? null : project;
+ const rows = resources.filter((r) => (!scoped || r.project_id === scoped) && (!kind || r.kind === kind));
+ const page = rows.slice(offset, offset + limit);
+ const nextOffset = offset + page.length;
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ resources: page, next_cursor: nextOffset < rows.length ? b64url(nextOffset) : null }));
+ }
+ if (req.method === 'GET' && url.pathname.startsWith('/api/v1/resources/')) {
+ const ovrn = decodeURIComponent(url.pathname.slice('/api/v1/resources/'.length));
+ const hit = resources.find((r) => r.ovrn === ovrn);
+ if (!hit) return problem(res, 404, 'resources.unknown_resource', `no resource named ${ovrn}`);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify(hit));
+ }
+ return problem(res, 404, 'route.not_found', url.pathname);
+ });
+ const url = await listen(server);
+ return {
+ id, url, calls, resources,
+ /** The registry entry the app is given in place of a manifest-derived authority. */
+ descriptor: {
+ id, name: `Stub ${id}`, internalOrigin: url, publicOrigin: `https://${id}.test`,
+ audience: `openvibe.${id}`, capability: `${id}.resource.read`, manifestVersion: '0.0.1',
+ contractsRange: null, status: 'alpha',
+ },
+ close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),
+ };
+}
+
+/** The authority registry shape server/authorities/index.js returns, over stubs (or descriptors). */
+function registryOf(entries) {
+ const byId = new Map(entries.map((e) => [e.id, e.descriptor || e]));
+ return { list: () => [...byId.values()], get: (id) => byId.get(id) || null, ids: () => [...byId.keys()], size: () => byId.size };
+}
+
+module.exports = { startNetwork, startAuthority, registryOf, summary };
new file mode 100644
new file mode 100644
@@ -0,0 +1,242 @@
@@ -0,0 +1,242 @@
+'use strict';
+/**
+ * The resource index over stub authorities (plan T13 step 6): the fan-out and merge (openvibe-sdk/
+ * resources), the merged page and its stable cursor, the project/kind/service filters, the person-facing
+ * scoping, an authority that times out or refuses (a partial page, never a failed one), the :ovrn route,
+ * and the token cache (one mint per authority).
+ *
+ * node test/index.test.js
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done, newProject } = require('./helpers/app');
+const { summary } = require('./helpers/stubs');
+
+const ovrnsOf = (body) => body.resources.map((r) => r.ovrn);
+
+async function main() {
+ const prjA = newProject();
+ const prjB = newProject();
+ const alpha = [
+ summary({ service: 'alpha', kind: 'alpha.repo', project: prjA }),
+ summary({ service: 'alpha', kind: 'alpha.repo', project: prjB }),
+ summary({ service: 'alpha', kind: 'alpha.queue', project: prjA }),
+ ];
+ const beta = [
+ summary({ service: 'beta', kind: 'beta.object', project: prjA }),
+ summary({ service: 'beta', kind: 'beta.object', project: prjA }),
+ ];
+ const gamma = [
+ summary({ service: 'gamma', kind: 'gamma.watch', project: prjA }),
+ ];
+ const t = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }] });
+ try {
+ await check('the merged page walks every authority with a stable cursor', async () => {
+ const seen = [];
+ let cursor = null;
+ let pages = 0;
+ do {
+ const r = await t.call('GET', `/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), null, 'no authority is partial here');
+ assert.ok(r.json.resources.length <= 2, 'limit=2');
+ seen.push(...ovrnsOf(r.json));
+ cursor = r.json.next_cursor;
+ pages++;
+ assert.ok(pages <= 6, 'the walk must end');
+ } while (cursor);
+ assert.strictEqual(pages, 3, 'six resources, two at a time');
+ const all = [...alpha, ...beta, ...gamma].map((r) => r.ovrn).sort();
+ assert.deepStrictEqual(seen.slice().sort(), all, 'every resource exactly once, none invented');
+ assert.deepStrictEqual(seen, all, 'a deterministic (service, ovrn, id) order');
+ assert.strictEqual(seen.filter((o) => o.startsWith('ovrn:alpha:')).length, 3);
+ });
+
+ await check('the cursor is stable: the same cursor answers the same page, and repeats nothing', async () => {
+ const first = await t.call('GET', '/api/v1/resources?limit=2', {});
+ assert.strictEqual(first.status, 200, first.text);
+ assert.ok(first.json.next_cursor, 'more pages than one');
+ const second = await t.call('GET', `/api/v1/resources?limit=2&cursor=${encodeURIComponent(first.json.next_cursor)}`, {});
+ const again = await t.call('GET', `/api/v1/resources?limit=2&cursor=${encodeURIComponent(first.json.next_cursor)}`, {});
+ assert.deepStrictEqual(ovrnsOf(second.json), ovrnsOf(again.json), 'the same cursor, the same page');
+ assert.strictEqual(ovrnsOf(second.json).length, 2);
+ assert.ok(ovrnsOf(second.json).every((o) => !ovrnsOf(first.json).includes(o)), 'no resource repeats across pages');
+ const firstAgain = await t.call('GET', '/api/v1/resources?limit=2', {});
+ assert.deepStrictEqual(ovrnsOf(firstAgain.json), ovrnsOf(first.json));
+ });
+
+ await check('?service= reads one authority, ?kind= one kind, ?project= one project', async () => {
+ const betaCalls = t.authority('beta').calls.length;
+ const byService = await t.call('GET', '/api/v1/resources?service=alpha', {});
+ assert.strictEqual(byService.status, 200, byService.text);
+ assert.deepStrictEqual(ovrnsOf(byService.json), alpha.map((r) => r.ovrn).sort());
+ assert.strictEqual(t.authority('beta').calls.length, betaCalls, '?service= never fans out to the others');
+
+ const byKind = await t.call('GET', '/api/v1/resources?kind=beta.object', {});
+ assert.strictEqual(byKind.status, 200, byKind.text);
+ assert.deepStrictEqual(ovrnsOf(byKind.json), beta.map((r) => r.ovrn).sort());
+
+ const byProject = await t.call('GET', `/api/v1/resources?project=${prjB}`, {});
+ assert.strictEqual(byProject.status, 200, byProject.text);
+ assert.deepStrictEqual(ovrnsOf(byProject.json), [alpha[1].ovrn]);
+
+ const both = await t.call('GET', `/api/v1/resources?service=alpha&kind=alpha.queue&project=${prjA}`, {});
+ assert.strictEqual(both.status, 200, both.text);
+ assert.deepStrictEqual(ovrnsOf(both.json), [alpha[2].ovrn]);
+
+ const unknown = await t.call('GET', '/api/v1/resources?service=nope', {});
+ assert.strictEqual(unknown.status, 400, unknown.text);
+ assert.strictEqual(unknown.json.code, 'resources.unknown_service');
+
+ for (const q of ['limit=0', 'limit=100000', 'limit=x', `project=not-a-project`, 'kind=Not A Kind', 'cursor=%25%25', 'service=Bad Service']) {
+ const r = await t.call('GET', `/api/v1/resources?${q}`, {});
+ assert.strictEqual(r.status, 400, `${q}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'resources.bad_query', q);
+ }
+ });
+
+ await check('a person reads only projects they belong to, one at a time', async () => {
+ const ada = ids.newId('user');
+ t.network.addMember(prjA, ada);
+
+ const unscoped = await t.call('GET', '/api/v1/resources', { token: t.network.signUser({ subject: ada }) });
+ assert.strictEqual(unscoped.status, 400, unscoped.text);
+ assert.strictEqual(unscoped.json.code, 'resources.project_required');
+
+ const mine = await t.call('GET', `/api/v1/resources?project=${prjA}`, { token: t.network.signUser({ subject: ada }) });
+ assert.strictEqual(mine.status, 200, mine.text);
+ const wanted = [...alpha, ...beta, ...gamma].filter((r) => r.project_id === prjA).map((r) => r.ovrn).sort();
+ assert.deepStrictEqual(ovrnsOf(mine.json), wanted, 'only prjA rows');
+ assert.ok(ovrnsOf(mine.json).includes(alpha[0].ovrn));
+ assert.ok(!ovrnsOf(mine.json).includes(alpha[1].ovrn), 'the prjB row of the same authority never shows');
+
+ const notMine = await t.call('GET', `/api/v1/resources?project=${prjB}`, { token: t.network.signUser({ subject: ada }) });
+ assert.strictEqual(notMine.status, 404, notMine.text);
+ assert.strictEqual(notMine.json.code, 'resources.project_not_found');
+ });
+
+ await check('an authority that ignores ?project= cannot leak another project into a person page', async () => {
+ const lenient = await boot({
+ authorities: [{ id: 'alpha', resources: alpha, ignoreProject: true }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }],
+ });
+ try {
+ const ada = ids.newId('user');
+ lenient.network.addMember(prjA, ada);
+ const r = await lenient.call('GET', `/api/v1/resources?project=${prjA}`, { token: lenient.network.signUser({ subject: ada }) });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!ovrnsOf(r.json).includes(alpha[1].ovrn), 'Services filters a row the authority should not have sent');
+ assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));
+ } finally { await lenient.close(); }
+ });
+
+ await check('a slow authority is omitted and named; the rest of the page is served', async () => {
+ const slow = await boot({
+ authorities: [
+ { id: 'alpha', resources: alpha },
+ { id: 'beta', resources: beta, hang: true },
+ { id: 'gamma', resources: gamma },
+ ],
+ });
+ try {
+ const r = await slow.call('GET', '/api/v1/resources');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');
+ const got = ovrnsOf(r.json);
+ assert.deepStrictEqual(got.slice().sort(), [...alpha, ...gamma].map((x) => x.ovrn).sort());
+ assert.deepStrictEqual(r.json.resources, r.json.resources.slice().sort((a, b) => (a.ovrn < b.ovrn ? -1 : 1)), 'partial does not disturb the order');
+ // the slow authority was asked, so it is partial because it did not answer, not because it was skipped
+ assert.ok(slow.authority('beta').calls.length > 0);
+ } finally { await slow.close(); }
+
+ const refusing = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta, status: 503 }] });
+ try {
+ const r = await refusing.call('GET', '/api/v1/resources');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');
+ assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));
+ } finally { await refusing.close(); }
+ });
+
+ await check('GET /api/v1/resources/:ovrn asks the owning authority and answers its summary', async () => {
+ const target = beta[0];
+ const r = await t.call('GET', `/api/v1/resources/${encodeURIComponent(target.ovrn)}`, {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json, target);
+ const asked = t.authority('beta').calls.filter((c) => c.path === `/api/v1/resources/${encodeURIComponent(target.ovrn)}`);
+ assert.strictEqual(asked.length, 1, 'asked exactly once of the owner');
+ assert.match(asked[0].authorization, /^Bearer svc-token:openvibe\.beta:/, 'with the authority’s own audience token');
+ assert.ok(!t.authority('alpha').calls.some((c) => c.path.includes(target.id)), 'never the other authorities');
+
+ // the authority's 404 is the caller's 404
+ const missing = { ...target, ovrn: `ovrn:beta:${target.project_id}:object/${ids.newId('watch')}` };
+ const notFound = await t.call('GET', `/api/v1/resources/${encodeURIComponent(missing.ovrn)}`, {});
+ assert.strictEqual(notFound.status, 404, notFound.text);
+ assert.strictEqual(notFound.json.code, 'resources.not_found');
+ });
+
+ await check('a bad name or an unknown service in an OVRN is 400', async () => {
+ const bad = await t.call('GET', '/api/v1/resources/not-an-ovrn', {});
+ assert.strictEqual(bad.status, 400, bad.text);
+ assert.strictEqual(bad.json.code, 'resources.bad_name');
+
+ const unknown = await t.call('GET', `/api/v1/resources/${encodeURIComponent(`ovrn:mystery:${prjA}:thing/${ids.newId('task')}`)}`, {});
+ assert.strictEqual(unknown.status, 400, unknown.text);
+ assert.strictEqual(unknown.json.code, 'resources.unknown_service');
+
+ // another project's OVRN parsed for a person who is not a member: 404, before any authority call
+ const ada = ids.newId('user');
+ const other = await t.call('GET', `/api/v1/resources/${encodeURIComponent(alpha[1].ovrn)}`, { token: t.network.signUser({ subject: ada }) });
+ assert.strictEqual(other.status, 404, other.text);
+ });
+
+ await check('the token cache mints one token per authority, and reuses it', async () => {
+ // A fresh boot, so the mints counted are this check's own (the cache is per process).
+ const fresh = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta }, { id: 'gamma', resources: gamma }] });
+ try {
+ const first = await fresh.call('GET', '/api/v1/resources');
+ assert.strictEqual(first.status, 200, first.text);
+ const mints = fresh.network.mints;
+ assert.strictEqual(mints.length, 3, 'one mint per authority');
+ assert.deepStrictEqual(mints.map((m) => m.audience).sort(), ['openvibe.alpha', 'openvibe.beta', 'openvibe.gamma']);
+ assert.deepStrictEqual(mints.map((m) => m.scope).sort(), ['alpha.resource.read', 'beta.resource.read', 'gamma.resource.read']);
+ assert.ok(mints.every((m) => m.client_id === 'services'));
+
+ const second = await fresh.call('GET', '/api/v1/resources');
+ assert.strictEqual(second.status, 200, second.text);
+ assert.strictEqual(fresh.network.mints.length, 3, 'a second page walk mints nothing new');
+
+ // and each authority only ever saw its own audience's token
+ for (const [id, audience] of [['alpha', 'openvibe.alpha'], ['beta', 'openvibe.beta'], ['gamma', 'openvibe.gamma']]) {
+ const auths = new Set(fresh.authority(id).calls.map((c) => c.authorization).filter(Boolean));
+ assert.deepStrictEqual([...auths], [`Bearer svc-token:${audience}:1`], `${id} saw only its own token`);
+ }
+ } finally { await fresh.close(); }
+ });
+
+ await check('a Network that cannot mint a token degrades the page, it never stops it', async () => {
+ const broken = await boot({ network: { failTokens: true }, authorities: [{ id: 'alpha', resources: alpha }] });
+ try {
+ const r = await broken.call('GET', '/api/v1/resources');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'alpha');
+ assert.deepStrictEqual(r.json.resources, []);
+ } finally { await broken.close(); }
+ });
+
+ await check('GET /api/v1/authorities lists the registry a service token reads', async () => {
+ const r = await t.call('GET', '/api/v1/authorities', {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.authorities.map((a) => a.id).sort(), ['alpha', 'beta', 'gamma']);
+ const a = r.json.authorities.find((x) => x.id === 'beta');
+ assert.strictEqual(a.origin, t.authority('beta').url);
+ assert.strictEqual(a.audience, 'openvibe.beta');
+ assert.strictEqual(a.capability, 'beta.resource.read');
+ assert.ok(!JSON.stringify(r.json).includes('services-secret'), 'never a secret');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+#!/usr/bin/env node
+/**
+ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any of
+ * them fails. They use an in-process stub of OpenVibe.Network and in-process stub authorities, temp
+ * databases and random ports; none needs the network or a running service.
+ *
+ * npm test # everything (PGlite)
+ * npm run test:pg # everything on the containers (SERVICES_TEST_STORE=pg)
+ * npm test -- index scaffold # only files whose name contains one of the words
+ */
+'use strict';
+// A file's wall timeout is generous: the test host is shared with other jobs, so a healthy file can take
+// several times its usual time. Files still run one at a time (parallel: 1) to keep the host's load down.
+require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 180000, pad: 28, parallel: 1 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,142 @@
@@ -0,0 +1,142 @@
+'use strict';
+/**
+ * The kernel around the resource index (plan T13 step 3): liveness, truthful readiness, the release
+ * manifest, the loopback-only /metrics, the problem+json surface and the token rule on the index.
+ *
+ * node test/scaffold.test.js
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+async function main() {
+ const t = await boot();
+ try {
+ await check('liveness: GET /api/health says only what it can back', async () => {
+ const r = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.ok, true);
+ assert.strictEqual(r.json.service, 'services');
+ assert.strictEqual(r.json.version, require('../package.json').version);
+ assert.strictEqual(r.json.authorities, 0); // no authority in this boot
+ assert.strictEqual(r.json.events.enabled, false); // EVENTS_URL unset in the test
+ assert.strictEqual(r.json.events.pending, 0); // no event type declared yet
+ });
+
+ await check('readiness: GET /api/ready reports the database and the Network key, and nothing else', async () => {
+ const r = await t.call('GET', '/api/ready', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.ready, true, r.text);
+ assert.strictEqual(r.json.status, 'ready', r.text);
+ assert.strictEqual(r.json.service, 'services');
+ assert.strictEqual(r.json.checks.db.status, 'ok');
+ assert.strictEqual(r.json.checks.db.required, true);
+ assert.strictEqual(r.json.checks.network_jwks.status, 'ok'); // the stub Network's key is loaded
+ assert.deepStrictEqual(r.json.failed, []);
+ assert.deepStrictEqual(r.json.skipped, [], 'readiness claims nothing it did not read');
+ assert.deepStrictEqual(r.json.authorities, { count: 0, ids: [] });
+ });
+
+ await check('GET /release.json serves the manifest for this release', async () => {
+ const r = await t.call('GET', '/release.json', { token: null });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.service, 'services');
+ assert.strictEqual(r.json.contracts_version, require('openvibe-contracts/package.json').version);
+ assert.strictEqual(r.json.packages['openvibe-sdk'], require('openvibe-sdk/package.json').version);
+ assert.ok(r.json.components.server, 'the release names its server component');
+ });
+
+ await check('GET /metrics is for a direct loopback caller, never a forwarded one', async () => {
+ const direct = await t.call('GET', '/metrics', { token: null });
+ assert.strictEqual(direct.status, 200, direct.text);
+ assert.match(direct.headers.get('content-type') || '', /text\/plain/);
+ assert.match(direct.text, /services_authorities/);
+ assert.match(direct.text, /release_info/);
+ for (const header of ['x-forwarded-for', 'x-real-ip', 'cf-connecting-ip', 'forwarded']) {
+ const forwarded = await t.call('GET', '/metrics', { token: null, headers: { [header]: '203.0.113.7' } });
+ assert.strictEqual(forwarded.status, 404, `${header} must not reach /metrics`);
+ }
+ const under = await t.call('GET', '/metrics/nope', { token: null });
+ assert.strictEqual(under.status, 404);
+ });
+
+ await check('an unknown route is a problem+json 404, page or API', async () => {
+ for (const p of ['/api/v1/nope', '/console', '/', '/nope']) {
+ const r = await t.call('GET', p, { token: null });
+ assert.strictEqual(r.status, 404, `${p}: ${r.text}`);
+ assert.match(r.headers.get('content-type') || '', /application\/problem\+json/, p);
+ assert.strictEqual(r.json.code, 'route.not_found', p);
+ assert.ok(r.json.request_id, p);
+ }
+ });
+
+ await check('the index without a token is 401 token.missing', async () => {
+ const ovrn = `ovrn:alpha:${ids.newId('project')}:task/${ids.newId('task')}`;
+ // An OVRN carries a '/', so a path parameter spells it URL-encoded (as the console will).
+ for (const p of ['/api/v1/resources', `/api/v1/resources/${encodeURIComponent(ovrn)}`, '/api/v1/authorities']) {
+ const r = await t.call('GET', p, { token: null });
+ assert.strictEqual(r.status, 401, `${p}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'token.missing');
+ }
+ });
+
+ await check('a service token without services.resource.read is 403 capability.denied', async () => {
+ const r = await t.call('GET', '/api/v1/resources', { cap: ['media.object.read'] });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'capability.denied');
+ });
+
+ await check('an app token is refused even with the grant: the index is first-party service tokens or a person', async () => {
+ const project = ids.newId('project');
+ const appToken = t.network.signService({ sub: `app:${ids.newId('app')}`, actor_type: 'app', project_id: project, env: 'production' });
+ const r = await t.call('GET', `/api/v1/resources?project=${project}`, { token: appToken });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'capability.denied');
+ assert.match(r.json.detail, /first-party/);
+ });
+
+ await check('a token for another audience is refused, never downgraded', async () => {
+ const r = await t.call('GET', '/api/v1/resources', { token: t.network.signService({ aud: ['openvibe.media'] }) });
+ assert.strictEqual(r.status, 401, r.text);
+ assert.ok(['token.wrong_audience', 'token.invalid'].includes(r.json.code), r.text);
+ });
+
+ await check('a person with no project is 400 resources.project_required, never an unscoped read', async () => {
+ const r = await t.call('GET', '/api/v1/resources', { user: {} });
+ assert.strictEqual(r.status, 400, r.text);
+ assert.strictEqual(r.json.code, 'resources.project_required');
+ });
+
+ await check('the authority registry is first-party: a person does not get it', async () => {
+ const r = await t.call('GET', '/api/v1/authorities', { user: {} });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json.code, 'capability.denied');
+ });
+
+ await check('the skeleton sends the security headers an API should', async () => {
+ const r = await t.call('GET', '/api/health', { token: null });
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
+ assert.ok(r.headers.get('x-frame-options'));
+ assert.strictEqual(r.headers.get('x-powered-by'), null);
+ assert.match(r.headers.get('x-openvibe-request-id') || '', /^req_[0-9a-f]{24}$/);
+ });
+ } finally {
+ await t.close();
+ }
+
+ // A second boot with a broken registry input: an empty authority set answers an empty page, not a crash.
+ const empty = await boot({ env: { OV_OAUTH_CLIENT_SECRET: '' } });
+ try {
+ await check('with no OAuth secret the index still serves (an empty registry is an empty page)', async () => {
+ const r = await empty.call('GET', '/api/v1/resources', {});
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json, { resources: [], next_cursor: null });
+ assert.strictEqual(empty.app.locals.tokens.enabled, false);
+ });
+ } finally {
+ await empty.close();
+ }
+ done();
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });