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)

OP OpenVibers committed 7 Oct 2026 · 1b4855e120 · 37 files, +4664 −0

Everything is built and green. Here is the final report.

## What was built

The OpenVibe.Services kernel (T13 step 3) plus the authority registry, per-authority adapters and the fan-out resource index (T13 step 6), in the `s1-services` worktree of the empty repo. **Nothing is committed or pushed** — HEAD is still `b665270`; all 37 files are untracked. Pins: openvibe-contracts v0.108.0, openvibe-sdk v0.34.0, openvibe-shared v2.11.0 (`package-lock.json` generated with `npm install --package-lock-only`, deps linked through `ds-deps.js`). Port **4930**, audience `openvibe.services`.

### File list (37)

```
package.json  package-lock.json  STATUS.json  README.md  SECURITY.md  CHANGELOG.md  LICENSE (AGPL-3.0)
.env.example  .gitignore  .gitleaks.toml  .github/workflows/ci.yml (contracts-service: services)
migrations/0001_initial.sql                      (7 tables: services_* — Services' own rows only)
deploy/systemd/openvibe-services.service  deploy/nginx/openvibe.services.conf
server/config.js  server/db.js  server/network.js  server/util.js  server/auth.js
server/authorities/index.js  server/authorities/tokens.js  server/authorities/adapter.js
server/resources.js  server/observability.js  server/events/outbox.js
server/api/v1.js  server/app.js  server/index.js
test/run.js  test/helpers/{app,db,stubs}.js
test/scaffold.test.js  test/index.test.js  test/config.test.js  test/authorities.test.js  test/db.test.js
```

### Route → capability table

| Route | Capability | Notes |
|---|---|---|
| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` (PROPOSED) | fan-out + merge via `openvibe-sdk/resources` `createResourceIndex`; keyset cursor; partial authorities in `X-OpenVibe-Partial-Authorities` |
| `GET /api/v1/resources/:ovrn` (URL-encoded) | `services.resource.read` (PROPOSED) | `contracts.resources.parse` → owning authority; 400 bad name/unknown service, 404 authority's 404, 502 unavailable |
| `GET /api/v1/authorities` | `services.resource.read` (PROPOSED) | first-party (`svc:`) only; Services-local shape, no contract yet |
| `GET /api/health`, `/api/ready`, `/release.json` | none | readiness requires db + network_jwks; a down authority is a partial page, not red |
| `GET /metrics` | none | loopback-direct only (proxy headers refused) |

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..f556274
---- /dev/null
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..ada0dfb
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0a08386
---- /dev/null
++++ b/.gitignore
@@@ -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
ddiff --git a/.gitleaks.toml b/.gitleaks.toml
nnew file mode 100644
iindex 0000000..333de0f
---- /dev/null
++++ b/.gitleaks.toml
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..d264f18
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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).
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -0,0 +1,661 @@
++ GNU AFFERO GENERAL PUBLIC LICENSE
++ Version 3, 19 November 2007
++
++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
++ Everyone is permitted to copy and distribute verbatim copies
++ of this license document, but changing it is not allowed.
++
++ Preamble
++
++ The GNU Affero General Public License is a free, copyleft license for
++software and other kinds of works, specifically designed to ensure
++cooperation with the community in the case of network server software.
++
++ The licenses for most software and other practical works are designed
++to take away your freedom to share and change the works. By contrast,
++our General Public Licenses are intended to guarantee your freedom to
++share and change all versions of a program--to make sure it remains free
++software for all its users.
++
++ When we speak of free software, we are referring to freedom, not
++price. Our General Public Licenses are designed to make sure that you
++have the freedom to distribute copies of free software (and charge for
++them if you wish), that you receive source code or can get it if you
++want it, that you can change the software or use pieces of it in new
++free programs, and that you know you can do these things.
++
++ Developers that use our General Public Licenses protect your rights
++with two steps: (1) assert copyright on the software, and (2) offer
++you this License which gives you legal permission to copy, distribute
++and/or modify the software.
++
++ A secondary benefit of defending all users' freedom is that
++improvements made in alternate versions of the program, if they
++receive widespread use, become available for other developers to
++incorporate. Many developers of free software are heartened and
++encouraged by the resulting cooperation. However, in the case of
++software used on network servers, this result may fail to come about.
++The GNU General Public License permits making a modified version and
++letting the public access it on a server without ever releasing its
++source code to the public.
++
++ The GNU Affero General Public License is designed specifically to
++ensure that, in such cases, the modified source code becomes available
++to the community. It requires the operator of a network server to
++provide the source code of the modified version running there to the
++users of that server. Therefore, public use of a modified version, on
++a publicly accessible server, gives the public access to the source
++code of the modified version.
++
++ An older license, called the Affero General Public License and
++published by Affero, was designed to accomplish similar goals. This is
++a different license, not a version of the Affero GPL, but Affero has
++released a new version of the Affero GPL which permits relicensing under
++this license.
++
++ The precise terms and conditions for copying, distribution and
++modification follow.
++
++ TERMS AND CONDITIONS
++
++ 0. Definitions.
++
++ "This License" refers to version 3 of the GNU Affero General Public License.
++
++ "Copyright" also means copyright-like laws that apply to other kinds of
++works, such as semiconductor masks.
++
++ "The Program" refers to any copyrightable work licensed under this
++License. Each licensee is addressed as "you". "Licensees" and
++"recipients" may be individuals or organizations.
++
++ To "modify" a work means to copy from or adapt all or part of the work
++in a fashion requiring copyright permission, other than the making of an
++exact copy. The resulting work is called a "modified version" of the
++earlier work or a work "based on" the earlier work.
++
++ A "covered work" means either the unmodified Program or a work based
++on the Program.
++
++ To "propagate" a work means to do anything with it that, without
++permission, would make you directly or secondarily liable for
++infringement under applicable copyright law, except executing it on a
++computer or modifying a private copy. Propagation includes copying,
++distribution (with or without modification), making available to the
++public, and in some countries other activities as well.
++
++ To "convey" a work means any kind of propagation that enables other
++parties to make or receive copies. Mere interaction with a user through
++a computer network, with no transfer of a copy, is not conveying.
++
++ An interactive user interface displays "Appropriate Legal Notices"
++to the extent that it includes a convenient and prominently visible
++feature that (1) displays an appropriate copyright notice, and (2)
++tells the user that there is no warranty for the work (except to the
++extent that warranties are provided), that licensees may convey the
++work under this License, and how to view a copy of this License. If
++the interface presents a list of user commands or options, such as a
++menu, a prominent item in the list meets this criterion.
++
++ 1. Source Code.
++
++ The "source code" for a work means the preferred form of the work
++for making modifications to it. "Object code" means any non-source
++form of a work.
++
++ A "Standard Interface" means an interface that either is an official
++standard defined by a recognized standards body, or, in the case of
++interfaces specified for a particular programming language, one that
++is widely used among developers working in that language.
++
++ The "System Libraries" of an executable work include anything, other
++than the work as a whole, that (a) is included in the normal form of
++packaging a Major Component, but which is not part of that Major
++Component, and (b) serves only to enable use of the work with that
++Major Component, or to implement a Standard Interface for which an
++implementation is available to the public in source code form. A
++"Major Component", in this context, means a major essential component
++(kernel, window system, and so on) of the specific operating system
++(if any) on which the executable work runs, or a compiler used to
++produce the work, or an object code interpreter used to run it.
++
++ The "Corresponding Source" for a work in object code form means all
++the source code needed to generate, install, and (for an executable
++work) run the object code and to modify the work, including scripts to
++control those activities. However, it does not include the work's
++System Libraries, or general-purpose tools or generally available free
++programs which are used unmodified in performing those activities but
++which are not part of the work. For example, Corresponding Source
++includes interface definition files associated with source files for
++the work, and the source code for shared libraries and dynamically
++linked subprograms that the work is specifically designed to require,
++such as by intimate data communication or control flow between those
++subprograms and other parts of the work.
++
++ The Corresponding Source need not include anything that users
++can regenerate automatically from other parts of the Corresponding
++Source.
++
++ The Corresponding Source for a work in source code form is that
++same work.
++
++ 2. Basic Permissions.
++
++ All rights granted under this License are granted for the term of
++copyright on the Program, and are irrevocable provided the stated
++conditions are met. This License explicitly affirms your unlimited
++permission to run the unmodified Program. The output from running a
++covered work is covered by this License only if the output, given its
++content, constitutes a covered work. This License acknowledges your
++rights of fair use or other equivalent, as provided by copyright law.
++
++ You may make, run and propagate covered works that you do not
++convey, without conditions so long as your license otherwise remains
++in force. You may convey covered works to others for the sole purpose
++of having them make modifications exclusively for you, or provide you
++with facilities for running those works, provided that you comply with
++the terms of this License in conveying all material for which you do
++not control copyright. Those thus making or running the covered works
++for you must do so exclusively on your behalf, under your direction
++and control, on terms that prohibit them from making any copies of
++your copyrighted material outside their relationship with you.
++
++ Conveying under any other circumstances is permitted solely under
++the conditions stated below. Sublicensing is not allowed; section 10
++makes it unnecessary.
++
++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
++
++ No covered work shall be deemed part of an effective technological
++measure under any applicable law fulfilling obligations under article
++11 of the WIPO copyright treaty adopted on 20 December 1996, or
++similar laws prohibiting or restricting circumvention of such
++measures.
++
++ When you convey a covered work, you waive any legal power to forbid
++circumvention of technological measures to the extent such circumvention
++is effected by exercising rights under this License with respect to
++the covered work, and you disclaim any intention to limit operation or
++modification of the work as a means of enforcing, against the work's
++users, your or third parties' legal rights to forbid circumvention of
++technological measures.
++
++ 4. Conveying Verbatim Copies.
++
++ You may convey verbatim copies of the Program's source code as you
++receive it, in any medium, provided that you conspicuously and
++appropriately publish on each copy an appropriate copyright notice;
++keep intact all notices stating that this License and any
++non-permissive terms added in accord with section 7 apply to the code;
++keep intact all notices of the absence of any warranty; and give all
++recipients a copy of this License along with the Program.
++
++ You may charge any price or no price for each copy that you convey,
++and you may offer support or warranty protection for a fee.
++
++ 5. Conveying Modified Source Versions.
++
++ You may convey a work based on the Program, or the modifications to
++produce it from the Program, in the form of source code under the
++terms of section 4, provided that you also meet all of these conditions:
++
++ a) The work must carry prominent notices stating that you modified
++ it, and giving a relevant date.
++
++ b) The work must carry prominent notices stating that it is
++ released under this License and any conditions added under section
++ 7. This requirement modifies the requirement in section 4 to
++ "keep intact all notices".
++
++ c) You must license the entire work, as a whole, under this
++ License to anyone who comes into possession of a copy. This
++ License will therefore apply, along with any applicable section 7
++ additional terms, to the whole of the work, and all its parts,
++ regardless of how they are packaged. This License gives no
++ permission to license the work in any other way, but it does not
++ invalidate such permission if you have separately received it.
++
++ d) If the work has interactive user interfaces, each must display
++ Appropriate Legal Notices; however, if the Program has interactive
++ interfaces that do not display Appropriate Legal Notices, your
++ work need not make them do so.
++
++ A compilation of a covered work with other separate and independent
++works, which are not by their nature extensions of the covered work,
++and which are not combined with it such as to form a larger program,
++in or on a volume of a storage or distribution medium, is called an
++"aggregate" if the compilation and its resulting copyright are not
++used to limit the access or legal rights of the compilation's users
++beyond what the individual works permit. Inclusion of a covered work
++in an aggregate does not cause this License to apply to the other
++parts of the aggregate.
++
++ 6. Conveying Non-Source Forms.
++
++ You may convey a covered work in object code form under the terms
++of sections 4 and 5, provided that you also convey the
++machine-readable Corresponding Source under the terms of this License,
++in one of these ways:
++
++ a) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by the
++ Corresponding Source fixed on a durable physical medium
++ customarily used for software interchange.
++
++ b) Convey the object code in, or embodied in, a physical product
++ (including a physical distribution medium), accompanied by a
++ written offer, valid for at least three years and valid for as
++ long as you offer spare parts or customer support for that product
++ model, to give anyone who possesses the object code either (1) a
++ copy of the Corresponding Source for all the software in the
++ product that is covered by this License, on a durable physical
++ medium customarily used for software interchange, for a price no
++ more than your reasonable cost of physically performing this
++ conveying of source, or (2) access to copy the
++ Corresponding Source from a network server at no charge.
++
++ c) Convey individual copies of the object code with a copy of the
++ written offer to provide the Corresponding Source. This
++ alternative is allowed only occasionally and noncommercially, and
++ only if you received the object code with such an offer, in accord
++ with subsection 6b.
++
++ d) Convey the object code by offering access from a designated
++ place (gratis or for a charge), and offer equivalent access to the
++ Corresponding Source in the same way through the same place at no
++ further charge. You need not require recipients to copy the
++ Corresponding Source along with the object code. If the place to
++ copy the object code is a network server, the Corresponding Source
++ may be on a different server (operated by you or a third party)
++ that supports equivalent copying facilities, provided you maintain
++ clear directions next to the object code saying where to find the
++ Corresponding Source. Regardless of what server hosts the
++ Corresponding Source, you remain obligated to ensure that it is
++ available for as long as needed to satisfy these requirements.
++
++ e) Convey the object code using peer-to-peer transmission, provided
++ you inform other peers where the object code and Corresponding
++ Source of the work are being offered to the general public at no
++ charge under subsection 6d.
++
++ A separable portion of the object code, whose source code is excluded
++from the Corresponding Source as a System Library, need not be
++included in conveying the object code work.
++
++ A "User Product" is either (1) a "consumer product", which means any
++tangible personal property which is normally used for personal, family,
++or household purposes, or (2) anything designed or sold for incorporation
++into a dwelling. In determining whether a product is a consumer product,
++doubtful cases shall be resolved in favor of coverage. For a particular
++product received by a particular user, "normally used" refers to a
++typical or common use of that class of product, regardless of the status
++of the particular user or of the way in which the particular user
++actually uses, or expects or is expected to use, the product. A product
++is a consumer product regardless of whether the product has substantial
++commercial, industrial or non-consumer uses, unless such uses represent
++the only significant mode of use of the product.
++
++ "Installation Information" for a User Product means any methods,
++procedures, authorization keys, or other information required to install
++and execute modified versions of a covered work in that User Product from
++a modified version of its Corresponding Source. The information must
++suffice to ensure that the continued functioning of the modified object
++code is in no case prevented or interfered with solely because
++modification has been made.
++
++ If you convey an object code work under this section in, or with, or
++specifically for use in, a User Product, and the conveying occurs as
++part of a transaction in which the right of possession and use of the
++User Product is transferred to the recipient in perpetuity or for a
++fixed term (regardless of how the transaction is characterized), the
++Corresponding Source conveyed under this section must be accompanied
++by the Installation Information. But this requirement does not apply
++if neither you nor any third party retains the ability to install
++modified object code on the User Product (for example, the work has
++been installed in ROM).
++
++ The requirement to provide Installation Information does not include a
++requirement to continue to provide support service, warranty, or updates
++for a work that has been modified or installed by the recipient, or for
++the User Product in which it has been modified or installed. Access to a
++network may be denied when the modification itself materially and
++adversely affects the operation of the network or violates the rules and
++protocols for communication across the network.
++
++ Corresponding Source conveyed, and Installation Information provided,
++in accord with this section must be in a format that is publicly
++documented (and with an implementation available to the public in
++source code form), and must require no special password or key for
++unpacking, reading or copying.
++
++ 7. Additional Terms.
++
++ "Additional permissions" are terms that supplement the terms of this
++License by making exceptions from one or more of its conditions.
++Additional permissions that are applicable to the entire Program shall
++be treated as though they were included in this License, to the extent
++that they are valid under applicable law. If additional permissions
++apply only to part of the Program, that part may be used separately
++under those permissions, but the entire Program remains governed by
++this License without regard to the additional permissions.
++
++ When you convey a copy of a covered work, you may at your option
++remove any additional permissions from that copy, or from any part of
++it. (Additional permissions may be written to require their own
++removal in certain cases when you modify the work.) You may place
++additional permissions on material, added by you to a covered work,
++for which you have or can give appropriate copyright permission.
++
++ Notwithstanding any other provision of this License, for material you
++add to a covered work, you may (if authorized by the copyright holders of
++that material) supplement the terms of this License with terms:
++
++ a) Disclaiming warranty or limiting liability differently from the
++ terms of sections 15 and 16 of this License; or
++
++ b) Requiring preservation of specified reasonable legal notices or
++ author attributions in that material or in the Appropriate Legal
++ Notices displayed by works containing it; or
++
++ c) Prohibiting misrepresentation of the origin of that material, or
++ requiring that modified versions of such material be marked in
++ reasonable ways as different from the original version; or
++
++ d) Limiting the use for publicity purposes of names of licensors or
++ authors of the material; or
++
++ e) Declining to grant rights under trademark law for use of some
++ trade names, trademarks, or service marks; or
++
++ f) Requiring indemnification of licensors and authors of that
++ material by anyone who conveys the material (or modified versions of
++ it) with contractual assumptions of liability to the recipient, for
++ any liability that these contractual assumptions directly impose on
++ those licensors and authors.
++
++ All other non-permissive additional terms are considered "further
++restrictions" within the meaning of section 10. If the Program as you
++received it, or any part of it, contains a notice stating that it is
++governed by this License along with a term that is a further
++restriction, you may remove that term. If a license document contains
++a further restriction but permits relicensing or conveying under this
++License, you may add to a covered work material governed by the terms
++of that license document, provided that the further restriction does
++not survive such relicensing or conveying.
++
++ If you add terms to a covered work in accord with this section, you
++must place, in the relevant source files, a statement of the
++additional terms that apply to those files, or a notice indicating
++where to find the applicable terms.
++
++ Additional terms, permissive or non-permissive, may be stated in the
++form of a separately written license, or stated as exceptions;
++the above requirements apply either way.
++
++ 8. Termination.
++
++ You may not propagate or modify a covered work except as expressly
++provided under this License. Any attempt otherwise to propagate or
++modify it is void, and will automatically terminate your rights under
++this License (including any patent licenses granted under the third
++paragraph of section 11).
++
++ However, if you cease all violation of this License, then your
++license from a particular copyright holder is reinstated (a)
++provisionally, unless and until the copyright holder explicitly and
++finally terminates your license, and (b) permanently, if the copyright
++holder fails to notify you of the violation by some reasonable means
++prior to 60 days after the cessation.
++
++ Moreover, your license from a particular copyright holder is
++reinstated permanently if the copyright holder notifies you of the
++violation by some reasonable means, this is the first time you have
++received notice of violation of this License (for any work) from that
++copyright holder, and you cure the violation prior to 30 days after
++your receipt of the notice.
++
++ Termination of your rights under this section does not terminate the
++licenses of parties who have received copies or rights from you under
++this License. If your rights have been terminated and not permanently
++reinstated, you do not qualify to receive new licenses for the same
++material under section 10.
++
++ 9. Acceptance Not Required for Having Copies.
++
++ You are not required to accept this License in order to receive or
++run a copy of the Program. Ancillary propagation of a covered work
++occurring solely as a consequence of using peer-to-peer transmission
++to receive a copy likewise does not require acceptance. However,
++nothing other than this License grants you permission to propagate or
++modify any covered work. These actions infringe copyright if you do
++not accept this License. Therefore, by modifying or propagating a
++covered work, you indicate your acceptance of this License to do so.
++
++ 10. Automatic Licensing of Downstream Recipients.
++
++ Each time you convey a covered work, the recipient automatically
++receives a license from the original licensors, to run, modify and
++propagate that work, subject to this License. You are not responsible
++for enforcing compliance by third parties with this License.
++
++ An "entity transaction" is a transaction transferring control of an
++organization, or substantially all assets of one, or subdividing an
++organization, or merging organizations. If propagation of a covered
++work results from an entity transaction, each party to that
++transaction who receives a copy of the work also receives whatever
++licenses to the work the party's predecessor in interest had or could
++give under the previous paragraph, plus a right to possession of the
++Corresponding Source of the work from the predecessor in interest, if
++the predecessor has it or can get it with reasonable efforts.
++
++ You may not impose any further restrictions on the exercise of the
++rights granted or affirmed under this License. For example, you may
++not impose a license fee, royalty, or other charge for exercise of
++rights granted under this License, and you may not initiate litigation
++(including a cross-claim or counterclaim in a lawsuit) alleging that
++any patent claim is infringed by making, using, selling, offering for
++sale, or importing the Program or any portion of it.
++
++ 11. Patents.
++
++ A "contributor" is a copyright holder who authorizes use under this
++License of the Program or a work on which the Program is based. The
++work thus licensed is called the contributor's "contributor version".
++
++ A contributor's "essential patent claims" are all patent claims
++owned or controlled by the contributor, whether already acquired or
++hereafter acquired, that would be infringed by some manner, permitted
++by this License, of making, using, or selling its contributor version,
++but do not include claims that would be infringed only as a
++consequence of further modification of the contributor version. For
++purposes of this definition, "control" includes the right to grant
++patent sublicenses in a manner consistent with the requirements of
++this License.
++
++ Each contributor grants you a non-exclusive, worldwide, royalty-free
++patent license under the contributor's essential patent claims, to
++make, use, sell, offer for sale, import and otherwise run, modify and
++propagate the contents of its contributor version.
++
++ In the following three paragraphs, a "patent license" is any express
++agreement or commitment, however denominated, not to enforce a patent
++(such as an express permission to practice a patent or covenant not to
++sue for patent infringement). To "grant" such a patent license to a
++party means to make such an agreement or commitment not to enforce a
++patent against the party.
++
++ If you convey a covered work, knowingly relying on a patent license,
++and the Corresponding Source of the work is not available for anyone
++to copy, free of charge and under the terms of this License, through a
++publicly available network server or other readily accessible means,
++then you must either (1) cause the Corresponding Source to be so
++available, or (2) arrange to deprive yourself of the benefit of the
++patent license for this particular work, or (3) arrange, in a manner
++consistent with the requirements of this License, to extend the patent
++license to downstream recipients. "Knowingly relying" means you have
++actual knowledge that, but for the patent license, your conveying the
++covered work in a country, or your recipient's use of the covered work
++in a country, would infringe one or more identifiable patents in that
++country that you have reason to believe are valid.
++
++ If, pursuant to or in connection with a single transaction or
++arrangement, you convey, or propagate by procuring conveyance of, a
++covered work, and grant a patent license to some of the parties
++receiving the covered work authorizing them to use, propagate, modify
++or convey a specific copy of the covered work, then the patent license
++you grant is automatically extended to all recipients of the covered
++work and works based on it.
++
++ A patent license is "discriminatory" if it does not include within
++the scope of its coverage, prohibits the exercise of, or is
++conditioned on the non-exercise of one or more of the rights that are
++specifically granted under this License. You may not convey a covered
++work if you are a party to an arrangement with a third party that is
++in the business of distributing software, under which you make payment
++to the third party based on the extent of your activity of conveying
++the work, and under which the third party grants, to any of the
++parties who would receive the covered work from you, a discriminatory
++patent license (a) in connection with copies of the covered work
++conveyed by you (or copies made from those copies), or (b) primarily
++for and in connection with specific products or compilations that
++contain the covered work, unless you entered into that arrangement,
++or that patent license was granted, prior to 28 March 2007.
++
++ Nothing in this License shall be construed as excluding or limiting
++any implied license or other defenses to infringement that may
++otherwise be available to you under applicable patent law.
++
++ 12. No Surrender of Others' Freedom.
++
++ If conditions are imposed on you (whether by court order, agreement or
++otherwise) that contradict the conditions of this License, they do not
++excuse you from the conditions of this License. If you cannot convey a
++covered work so as to satisfy simultaneously your obligations under this
++License and any other pertinent obligations, then as a consequence you may
++not convey it at all. For example, if you agree to terms that obligate you
++to collect a royalty for further conveying from those to whom you convey
++the Program, the only way you could satisfy both those terms and this
++License would be to refrain entirely from conveying the Program.
++
++ 13. Remote Network Interaction; Use with the GNU General Public License.
++
++ Notwithstanding any other provision of this License, if you modify the
++Program, your modified version must prominently offer all users
++interacting with it remotely through a computer network (if your version
++supports such interaction) an opportunity to receive the Corresponding
++Source of your version by providing access to the Corresponding Source
++from a network server at no charge, through some standard or customary
++means of facilitating copying of software. This Corresponding Source
++shall include the Corresponding Source for any work covered by version 3
++of the GNU General Public License that is incorporated pursuant to the
++following paragraph.
++
++ Notwithstanding any other provision of this License, you have
++permission to link or combine any covered work with a work licensed
++under version 3 of the GNU General Public License into a single
++combined work, and to convey the resulting work. The terms of this
++License will continue to apply to the part which is the covered work,
++but the work with which it is combined will remain governed by version
++3 of the GNU General Public License.
++
++ 14. Revised Versions of this License.
++
++ The Free Software Foundation may publish revised and/or new versions of
++the GNU Affero General Public License from time to time. Such new versions
++will be similar in spirit to the present version, but may differ in detail to
++address new problems or concerns.
++
++ Each version is given a distinguishing version number. If the
++Program specifies that a certain numbered version of the GNU Affero General
++Public License "or any later version" applies to it, you have the
++option of following the terms and conditions either of that numbered
++version or of any later version published by the Free Software
++Foundation. If the Program does not specify a version number of the
++GNU Affero General Public License, you may choose any version ever published
++by the Free Software Foundation.
++
++ If the Program specifies that a proxy can decide which future
++versions of the GNU Affero General Public License can be used, that proxy's
++public statement of acceptance of a version permanently authorizes you
++to choose that version for the Program.
++
++ Later license versions may give you additional or different
++permissions. However, no additional obligations are imposed on any
++author or copyright holder as a result of your choosing to follow a
++later version.
++
++ 15. Disclaimer of Warranty.
++
++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
++
++ 16. Limitation of Liability.
++
++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
++SUCH DAMAGES.
++
++ 17. Interpretation of Sections 15 and 16.
++
++ If the disclaimer of warranty and limitation of liability provided
++above cannot be given local legal effect according to their terms,
++reviewing courts shall apply local law that most closely approximates
++an absolute waiver of all civil liability in connection with the
++Program, unless a warranty or assumption of liability accompanies a
++copy of the Program in return for a fee.
++
++ END OF TERMS AND CONDITIONS
++
++ How to Apply These Terms to Your New Programs
++
++ If you develop a new program, and you want it to be of the greatest
++possible use to the public, the best way to achieve this is to make it
++free software which everyone can redistribute and change under these terms.
++
++ To do so, attach the following notices to the program. It is safest
++to attach them to the start of each source file to most effectively
++state the exclusion of warranty; and each file should have at least
++the "copyright" line and a pointer to where the full notice is found.
++
++ <one line to give the program's name and a brief idea of what it does.>
++ Copyright (C) <year> <name of author>
++
++ This program is free software: you can redistribute it and/or modify
++ it under the terms of the GNU Affero General Public License as published
++ by the Free Software Foundation, either version 3 of the License, or
++ (at your option) any later version.
++
++ This program is distributed in the hope that it will be useful,
++ but WITHOUT ANY WARRANTY; without even the implied warranty of
++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
++ GNU Affero General Public License for more details.
++
++ You should have received a copy of the GNU Affero General Public License
++ along with this program. If not, see <https://www.gnu.org/licenses/>.
++
++Also add information on how to contact you by electronic and paper mail.
++
++ If your software can interact with users remotely through a computer
++network, you should also make sure that it provides a way for users to
++get its source. For example, if your program is a web application, its
++interface could display a "Source" link that leads users to an archive
++of the code. There are many ways you could offer source, and different
++solutions will be better for different programs; see section 13 for the
++specific requirements.
++
++ You should also get your employer (if you work as a programmer) or school,
++if any, to sign a "copyright disclaimer" for the program, if necessary.
++For more information on this, and how to apply and follow the GNU AGPL, see
++<https://www.gnu.org/licenses/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..22c333e
---- /dev/null
++++ b/README.md
@@@ -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 -->
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..523da5c
---- /dev/null
++++ b/SECURITY.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..9119b5d
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/nginx/openvibe.services.conf b/deploy/nginx/openvibe.services.conf
nnew file mode 100644
iindex 0000000..2118513
---- /dev/null
++++ b/deploy/nginx/openvibe.services.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-services.service b/deploy/systemd/openvibe-services.service
nnew file mode 100644
iindex 0000000..0cb282d
---- /dev/null
++++ b/deploy/systemd/openvibe-services.service
@@@ -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
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..e9565a0
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..462bd75
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..efd2c46
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..1827490
---- /dev/null
++++ b/server/api/v1.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..b51a702
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..1846dc4
---- /dev/null
++++ b/server/auth.js
@@@ -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 };
ddiff --git a/server/authorities/adapter.js b/server/authorities/adapter.js
nnew file mode 100644
iindex 0000000..6d74b1d
---- /dev/null
++++ b/server/authorities/adapter.js
@@@ -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 };
ddiff --git a/server/authorities/index.js b/server/authorities/index.js
nnew file mode 100644
iindex 0000000..066026f
---- /dev/null
++++ b/server/authorities/index.js
@@@ -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 };
ddiff --git a/server/authorities/tokens.js b/server/authorities/tokens.js
nnew file mode 100644
iindex 0000000..9cef46c
---- /dev/null
++++ b/server/authorities/tokens.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..07ed28f
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..4ce680a
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..49aeded
---- /dev/null
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..eab3e57
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..d4f6a94
---- /dev/null
++++ b/server/network.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..d07d1de
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/resources.js b/server/resources.js
nnew file mode 100644
iindex 0000000..92c921a
---- /dev/null
++++ b/server/resources.js
@@@ -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 };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..909d665
---- /dev/null
++++ b/server/util.js
@@@ -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 };
ddiff --git a/test/authorities.test.js b/test/authorities.test.js
nnew file mode 100644
iindex 0000000..c7929dc
---- /dev/null
++++ b/test/authorities.test.js
@@@ -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); });
ddiff --git a/test/config.test.js b/test/config.test.js
nnew file mode 100644
iindex 0000000..e3be539
---- /dev/null
++++ b/test/config.test.js
@@@ -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); });
ddiff --git a/test/db.test.js b/test/db.test.js
nnew file mode 100644
iindex 0000000..32d0d3f
---- /dev/null
++++ b/test/db.test.js
@@@ -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); });
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..e335c91
---- /dev/null
++++ b/test/helpers/app.js
@@@ -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 };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..7e27fa1
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
nnew file mode 100644
iindex 0000000..a83c605
---- /dev/null
++++ b/test/helpers/stubs.js
@@@ -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 };
ddiff --git a/test/index.test.js b/test/index.test.js
nnew file mode 100644
iindex 0000000..35f873d
---- /dev/null
++++ b/test/index.test.js
@@@ -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); });
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..7852519
---- /dev/null
++++ b/test/run.js
@@@ -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 });
ddiff --git a/test/scaffold.test.js b/test/scaffold.test.js
nnew file mode 100644
iindex 0000000..0dd6c27
---- /dev/null
++++ b/test/scaffold.test.js
@@@ -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); });