openvibers/openvibe.ai Commits

AI: the public home at ai.openvibe.services, developer-app routes on the public vhost, contracts 0.109.0 (#23)

OP OpenVibers committed 7 Oct 2026 · e6afde7436 · 15 files, +402 −640

Changes

ddiff --git a/.env.example b/.env.example
iindex 3207045..4bf18f0 100644
---- a/.env.example
++++ b/.env.example
@@@ -1,6 +1,6 @@
# ═══════════════════════════════════════════════════════════════
--# ai.openvibe.network — OpenVibe.AI Environment Configuration
--# Target host: ai.openvibe.services (the public home; BASE_URL still defaults to ai.openvibe.network here).
++# ai.openvibe.services — OpenVibe.AI Environment Configuration
++# The public home and API origin (ai.openvibe.network answers 301 to it). BASE_URL defaults to it in production.
# Copy to .env and fill in values. In production this lives at /etc/openvibe/ai.env (0600).
# Secrets are read by NAME only (providers store 'env:NAME' references); nothing logs or returns them.
# ═══════════════════════════════════════════════════════════════
@@@ -35,8 +35,7 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
# ── Operator console (/console; README "Operator console") ──
# Network SSO as OAuth client `ai` (the client AI's service tokens use). The Network must list
--# ${BASE_URL}/auth/callback (https://ai.openvibe.network/auth/callback) as a redirect URI of client `ai`.
--# Target host: also list https://ai.openvibe.services/auth/callback when BASE_URL moves there.
++# ${BASE_URL}/auth/callback (https://ai.openvibe.services/auth/callback) as a redirect URI of client `ai`.
# OV_OAUTH_CLIENT_SECRET is also what the events relay and the user modules authenticate with.
#OV_OAUTH_CLIENT_ID=ai
#OV_OAUTH_CLIENT_SECRET=
ddiff --git a/README.md b/README.md
iindex 514f57f..7f3cf6a 100644
---- a/README.md
++++ b/README.md
@@@ -2,11 +2,11 @@
> Providers, models, routing, prompt templates, workflows, runs, citations, cache and quotas for every product.
--**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700`
--(loopback only) and in production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's
--footer copy runs `network.site_copy` here. It is not a public product: the domain keeps its placeholder
--page until the launch rule below is met.
--**Domain:** `ai.openvibe.network` (target host `ai.openvibe.services`) · **Port:** 4700 · **Unit:** `openvibe-ai.service`
++**Status:** alpha (roadmap Wave 13). Deployed on the host since 2026-09-23 on `127.0.0.1:4700` and in
++production use: OpenVibe.Live runs with `AI_SERVICE=remote` and Network's footer copy runs
++`network.site_copy` here. Public since 2026-10-07 at [ai.openvibe.services](https://ai.openvibe.services):
++the home (`server/home.js`), `/stats` and the developer-app API (`ai.app.run`).
++**Domain:** `ai.openvibe.services` (`ai.openvibe.network` answers 301 to it) · **Port:** 4700 · **Unit:** `openvibe-ai.service`
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §12.1, §12.13, §15.14, §33, §34.
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -51,9 +51,8 @@ owns publication truth: Wiki, Blog, News, Live, … decide what to publish.
Implemented here (the service manifest's `capabilities`, audience `openvibe.ai`; routes under
[API](#api)): `ai.run.create`, `ai.run.read`, `ai.workflow.manage`, `ai.provider.manage`,
`ai.usage.read`, `ai.credential.manage` (`/api/v1/credentials/:subject`),
--`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; a
--public capability proposed in `docs/capabilities-proposal/` until openvibe-contracts registers it —
--`server/auth.js` enforces it through its PROPOSED fallback until then). A service token's `ns`
++`ai.quota.attribution.manage` (per-streamer quotas) and `ai.app.run` (developer apps, ADR-014; public,
++registered since openvibe-contracts 0.109.0). A service token's `ns`
claim limits which workflow namespaces it may run; an app token is fixed to its project's.
Called elsewhere, as the service principal `ai`: `events.event.publish` (Events, the outbox relay)
@@@ -145,10 +144,10 @@ and its rollback lever (`AI_NS_REQUIRED`) were retired on 2026-09-28 (shims C-22
Roadmap Wave 20, ADR-014. A developer app gets a token from OpenVibe.Network (`POST /oauth/token`,
`grant_type=client_credentials` with its `app_<ULID>` client, `audience=openvibe.ai`): `sub
app:app_<ULID>`, `project_id prj_<ULID>`, `env sandbox|production`, ns `[project_id,
--app.<project_id>.*]`, five minutes. AI accepts one capability for it, `ai.app.run` (public;
--proposed in `docs/capabilities-proposal/ai.app.run.json` until openvibe-contracts registers it —
--until then `server/auth.js` enforces it through its PROPOSED fallback, as Events did before
--`events.app.*` shipped):
++app.<project_id>.*]`, five minutes. AI accepts one capability for it, `ai.app.run` (public). A
++sandbox app holds it without asking (Network's default sandbox allowance); a production app holds it
++once OpenVibe staff add it to the project's allowance. Create the project and its app at
++[openvibe.codes/projects](https://openvibe.codes/projects):
| Route | What |
|---|---|
@@@ -178,7 +177,7 @@ project's runs and never uses a person's BYO provider key: the `byok` provider p
offered to an app run, and `credential` on its request is refused.
```bash
--AI=https://ai.openvibe.network
++AI=https://ai.openvibe.services
APP=app_01JAB… # the app's client id; SECRET is its client secret
TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \
-d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \
@@@ -194,7 +193,7 @@ AI takes one on these routes alone, and every other route refuses `env: sandbox`
## Operator console
--`/console` on ai.openvibe.network (roadmap WS-O task 4) is a server-rendered staff console: no
++`/console` on ai.openvibe.services (roadmap WS-O task 4) is a server-rendered staff console: no
JavaScript, one inline stylesheet allowed by its CSP hash, `noindex` (meta and `X-Robots-Tag`),
`no-store`, disallowed in `robots.txt`, in no sitemap. It does not load the OpenVibe Frame (the Frame
needs scripts and inline styles), like Billing's staff console. Code: `server/console/`.
@@@ -440,19 +439,15 @@ runs as `openvibe-llm.service` on 127.0.0.1:8090 ([Local model](#local-model)).
Rollback: ovhost puts the previous sha back by itself when `/api/ready` does not answer 2xx after the
restart; afterwards `sudo ovhost rollback ai --to <sha>`. Migrations only add tables and columns.
--Deployed: `/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600), unit `deploy/systemd/openvibe-ai.service`
--(`StateDirectory=openvibe-ai`; database `ov_ai` on the host's data role), principal `ai`. The nginx vhost
--`deploy/nginx/ai.openvibe.network.conf` (health/ready, `/`, `/robots.txt`, the operator console
--`/console` and its sign-in `/auth/` public; the API is host-local) is not installed:
--`ai.openvibe.network` still serves the Sites placeholder. `deploy/nginx/ai.openvibe.services.conf`
--mirrors that vhost for `ai.openvibe.services` (the same proxied locations to the same upstream,
--`/metrics` and the API still blocked, on the `*.openvibe.services` certificate the host already holds) and
--replaces the Sites placeholder for `ai.openvibe.services` when installed; it is not installed or live yet. The console also needs
--`AI_CONSOLE_SESSION_SECRET` (32+ random characters) and `OV_OAUTH_CLIENT_SECRET` in the env file
--(without them it answers 503 in production), and the Network must list
--`https://ai.openvibe.network/auth/callback` among client `ai`'s redirect URIs (and
--`https://ai.openvibe.services/auth/callback` once that vhost serves the console, since the callback is built from
--the base URL).
++Deployed: `/opt/openvibe.ai`, env `/etc/openvibe/ai.env` (0600; `BASE_URL=https://ai.openvibe.services`), unit
++`deploy/systemd/openvibe-ai.service` (`StateDirectory=openvibe-ai`; database `ov_ai` on the host's data role),
++principal `ai`. The vhost `deploy/nginx/ai.openvibe.services.conf` (on the `*.openvibe.services` certificate)
++serves the home, `/stats`, `/shared/*`, `/robots.txt`, `/sitemap.xml`, `/llms.txt`, health/ready, the operator
++console `/console` with its sign-in `/auth/`, and exactly the developer-app routes (the six operations by POST,
++`/api/v1/runs/:id` by GET, rate-limited per address); every other `/api/` path and `/metrics` stay host-local.
++`deploy/nginx/ai.openvibe.network.conf` answers 301 to the same path on ai.openvibe.services. The console needs
++`AI_CONSOLE_SESSION_SECRET` (32+ random characters) and `OV_OAUTH_CLIENT_SECRET` in the env file (without them it
++answers 503 in production); Network client `ai` lists `https://ai.openvibe.services/auth/callback`.
## Not done yet
@@@ -460,9 +455,8 @@ the base URL).
themselves; moving the remaining passthrough prompts into AI templates (most `live.*` workflows are
versioned templates since 2026-09-27);
removing Network's fallback to Live's `/internal/ai/site-copy`; a public server-rendered status page.
--- The operator console is built but not reachable yet: it needs the vhost below installed (which
-- replaces the Sites placeholder), `https://ai.openvibe.network/auth/callback` listed as a redirect URI
-- of Network OAuth client `ai`, and `AI_CONSOLE_SESSION_SECRET` in `/etc/openvibe/ai.env`.
++- Metered models for developer apps: production app runs use free and local capacity until a tier budget
++ governs their project (`AI_GOVERN_TIERS` is off in production) and OpenVibe.Billing charges project subjects.
- Import holds from the 2026-09-23 run: a streamer's own provider key stays in Live, and 3,021 Live
translations cannot become cache entries (they have no source text) and stay in Live.
- Text routes now run a capability pool (`shared` plus a local model when one is configured) instead
@@@ -473,23 +467,15 @@ the base URL).
- The host has a backup of `ov_ai` but no restore drill has run for it; `/metrics` is built but not
deployed yet.
--## Launch rule
++## Launch
--This repository does not make the product real, and the domain keeps its placeholder page on
--[OpenVibers/OpenVibe.Sites](https://github.com/OpenVibers/OpenVibe.Sites) until all of the
--following exist here (plan §12.12):
--
--1. an owning runtime with health/readiness endpoints and observability;
--2. canonical identity/auth integration (OpenVibe.Network subjects, scoped service principals);
--3. server-rendered or static public routes that are useful without JavaScript;
--4. real persistence and end-to-end workflows;
--5. capability and event registration against `OpenVibe.Contracts`;
--6. a migration/seed strategy, a security/threat review, and sitemap/robots/feed behaviour;
--7. acceptance tests proving the advertised functionality.
--
--The launch release removes the domain from `OpenVibe.Sites/sites.json`, switches routing and
--registers maturity in the ecosystem registry atomically. A placeholder is never counted as an
--implemented service.
++Launched 2026-10-07 under the launch rule (plan §12.12): the runtime with health, readiness and metrics; Network
++subjects and scoped service principals; server-rendered public routes useful without JavaScript (the home,
++`/stats`); PostgreSQL persistence and end-to-end workflows; capabilities and events registered in
++`OpenVibe.Contracts` (`ai.app.run` since 0.109.0, the service live since 0.110.0); migrations at boot,
++robots/sitemap/llms.txt; acceptance tests ([above](#acceptance-guarantees-and-where-they-are-tested), and
++`test/home.test.js` for the public routes and vhosts). The same release removed ai.openvibe.services and the
++ai.openvibe.network notice from OpenVibe.Sites.
---
ddiff --git a/deploy/nginx/ai.openvibe.network.conf b/deploy/nginx/ai.openvibe.network.conf
iindex f6295b3..13c0f5b 100644
---- a/deploy/nginx/ai.openvibe.network.conf
++++ b/deploy/nginx/ai.openvibe.network.conf
@@@ -2,19 +2,9 @@
## ai.openvibe.network — Nginx Configuration (REFERENCE)
##
## Deployed to /etc/nginx/sites-available/ai.openvibe.network.conf — keep them in sync, and reload
--## nginx after changing it. The domain keeps its OpenVibe.Sites placeholder until the launch rule in
--## README.md is met; this vhost is what replaces it then.
--##
--## The Node service (port 4700, systemd unit openvibe-ai) serves:
--## /api/health, /api/ready
--## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
--## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
--## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the
--## location below only when a service on another host needs it (it still needs a token).
--## /console the operator console (server-rendered, OpenVibe.Network staff only) and /auth/* its
--## Network sign-in (redirect URI https://ai.openvibe.network/auth/callback, OAuth client
--## `ai`); both noindex. /robots.txt keeps crawlers out of them.
--## /stats public price and latency per provider and model (server-rendered, no sign-in)
++## nginx after changing it. OpenVibe.AI's public origin is https://ai.openvibe.services
++## (deploy/nginx/ai.openvibe.services.conf); this name answers 301 to the same path there.
++## Services on this host never come through here: they call 127.0.0.1:4700 directly.
## ═══════════════════════════════════════════════════════════════
server {
@@@ -23,7 +13,7 @@ server {
server_name ai.openvibe.network;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
-- location / { return 301 https://ai.openvibe.network$request_uri; }
++ location / { return 301 https://ai.openvibe.services$request_uri; }
}
server {
@@@ -39,63 +29,7 @@ server {
access_log /var/log/nginx/ai.openvibe.network.access.log;
error_log /var/log/nginx/ai.openvibe.network.error.log;
-- # Inline images arrive as data URLs inside run inputs.
-- client_max_body_size 12m;
--
-- 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;
-- location = /api/health { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
-- location = /api/ready { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
--
-- # Metrics are for host-local scrapers only (http://127.0.0.1:4700/metrics).
-- location = /metrics { return 404; }
--
-- # ── Service API: host-local only (see header) ───────────
-- location /api/ {
-- return 403;
-- # To expose it (token-guarded) to off-host services instead:
-- # proxy_pass http://127.0.0.1:4700;
-- # proxy_http_version 1.1;
-- # proxy_set_header Host $host;
-- # proxy_set_header X-Real-IP $remote_addr; # client address from $remote_addr only (realip),
-- # proxy_set_header X-Forwarded-For $remote_addr; # never appended to or passed through
-- # proxy_set_header CF-Connecting-IP $remote_addr;
-- # proxy_set_header X-Forwarded-Proto $scheme;
-- # proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
-- }
--
-- # ── Operator console and its Network sign-in (staff only; the app refuses everyone else) ──
-- location /console {
-- proxy_pass http://127.0.0.1:4700;
-- proxy_set_header Host $host;
-- proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $remote_addr;
-- proxy_set_header CF-Connecting-IP $remote_addr;
-- proxy_set_header X-Forwarded-Proto $scheme;
-- }
-- location /auth/ {
-- proxy_pass http://127.0.0.1:4700;
-- proxy_set_header Host $host;
-- proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $remote_addr;
-- proxy_set_header CF-Connecting-IP $remote_addr;
-- proxy_set_header X-Forwarded-Proto $scheme;
-- }
-- location = /robots.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
--
-- location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
--
-- location = / {
-- proxy_pass http://127.0.0.1:4700;
-- proxy_set_header Host $host;
-- proxy_set_header X-Real-IP $remote_addr;
-- proxy_set_header X-Forwarded-For $remote_addr;
-- proxy_set_header CF-Connecting-IP $remote_addr;
-- }
--
-- location / {
-- return 404;
-- }
++ location / { return 301 https://ai.openvibe.services$request_uri; }
}
ddiff --git a/deploy/nginx/ai.openvibe.services.conf b/deploy/nginx/ai.openvibe.services.conf
iindex de6a112..9f2508d 100644
---- a/deploy/nginx/ai.openvibe.services.conf
++++ b/deploy/nginx/ai.openvibe.services.conf
@@@ -4,20 +4,28 @@
## Deployed to /etc/nginx/sites-available/ai.openvibe.services.conf — keep them in sync, and reload
## nginx after changing it. It uses the *.openvibe.services certificate the host already holds
## (/etc/letsencrypt/live/openvibe.services/); installed in place of the OpenVibe.Sites placeholder
--## vhost of the same name, it replaces that placeholder.
++## vhost of the same name, it replaces that placeholder. ai.openvibe.network (its own vhost) answers
++## 301 to here.
##
## The Node service (port 4700, systemd unit openvibe-ai) serves:
++## / the product home for a browser (server/home.js), the text API index for anything else
++## /stats public price and latency per provider and model (server-rendered, no sign-in)
++## /shared/* the home's OpenVibe Frame files (content-addressed); /robots.txt, /sitemap.xml, /llms.txt
## /api/health, /api/ready
++## /api/v1/{chat,generate,summarize,classify,extract,embed} (POST) and /api/v1/runs/:id (GET)
++## developer apps (ai.app.run, app tokens from OpenVibe.Network): open here, rate-limited
++## per address; the app checks the token on every request
++## /api/v1/* everything else is service-to-service (first-party service tokens). Services on this
++## host call 127.0.0.1:4700 directly, so the public vhost refuses it.
## /metrics Prometheus text, answered only to direct loopback callers (never proxied)
--## /api/v1/* service-to-service API (Network service tokens, audience openvibe.ai). Services on
--## this host call 127.0.0.1:4700 directly, so the public vhost refuses it; open the
--## location below only when a service on another host needs it (it still needs a token).
## /console the operator console (server-rendered, OpenVibe.Network staff only) and /auth/* its
## Network sign-in (redirect URI https://ai.openvibe.services/auth/callback, OAuth client
## `ai`); both noindex. /robots.txt keeps crawlers out of them.
--## /stats public price and latency per provider and model (server-rendered, no sign-in)
## ═══════════════════════════════════════════════════════════════
++# Developer-app calls per client address (after realip): a burst for interactive use, then 5 a second.
++limit_req_zone $binary_remote_addr zone=ovai_apps:10m rate=5r/s;
++
server {
listen 80;
listen [::]:80;
@@@ -53,6 +61,33 @@ server {
# Metrics are for host-local scrapers only (http://127.0.0.1:4700/metrics).
location = /metrics { return 404; }
++ # ── Developer apps (ai.app.run): the six direct operations and a run read ──
++ location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {
++ limit_req zone=ovai_apps burst=20 nodelay;
++ limit_req_status 429;
++ limit_except POST { deny all; }
++ proxy_pass http://127.0.0.1:4700;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr; # client address from $remote_addr only (realip),
++ proxy_set_header X-Forwarded-For $remote_addr; # never appended to or passed through
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ proxy_read_timeout 90s; # ?wait= holds a request up to 60 s
++ }
++ location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {
++ limit_req zone=ovai_apps burst=20 nodelay;
++ limit_req_status 429;
++ limit_except GET { deny all; }
++ proxy_pass http://127.0.0.1:4700;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
# ── Service API: host-local only (see header) ───────────
location /api/ {
return 403;
@@@ -87,6 +122,10 @@ server {
location = /robots.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = /stats { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /sitemap.xml { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /llms.txt { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location = /release.json { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
++ location /shared/ { proxy_pass http://127.0.0.1:4700; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header CF-Connecting-IP $remote_addr; }
location = / {
proxy_pass http://127.0.0.1:4700;
ddiff --git a/docs/capabilities-proposal/service-manifest.ai.json b/docs/capabilities-proposal/service-manifest.ai.json
iindex a30dda3..6381751 100644
---- a/docs/capabilities-proposal/service-manifest.ai.json
++++ b/docs/capabilities-proposal/service-manifest.ai.json
@@@ -8,16 +8,17 @@
"status": "alpha",
"repository": "OpenVibers/OpenVibe.AI",
"domains": [
++ "ai.openvibe.services",
"ai.openvibe.network"
],
-- "publicOrigin": "https://ai.openvibe.network",
++ "publicOrigin": "https://ai.openvibe.services",
"health": "/api/health",
"ready": "/api/ready",
"internalOrigin": "http://127.0.0.1:4700",
"exposure": {
-- "state": "internal",
-- "publicSite": null,
-- "note": "called by other services at 127.0.0.1:4700; ai.openvibe.network serves the OpenVibe.Sites placeholder page"
++ "state": "live",
++ "publicSite": "service",
++ "note": "the public home, /stats and the developer-app API at ai.openvibe.services (2026-10-07); ai.openvibe.network answers 301 to it; services on the host call 127.0.0.1:4700"
},
"capabilities": [
"ai.app.run",
@@@ -76,14 +77,14 @@
],
"window": "automatic only while ovhost waits for readiness after the restart (30 s in host.example.json); manual afterwards, as long as no blocker applies",
"blockers": {
-- "none": "the schema code only adds (CREATE … IF NOT EXISTS, ADD COLUMN; no DROP, RENAME or table rebuild), which an older release ignores"
++ "none": "the schema code only adds (CREATE \u2026 IF NOT EXISTS, ADD COLUMN; no DROP, RENAME or table rebuild), which an older release ignores"
}
},
"contracts": {
"range": ">=0.6.0 <1.0.0"
},
"leases": {
-- "none": "no claims: one process owns ai.db (better-sqlite3, single writer); a run is taken with a compare-and-set on its status (queued → running)"
++ "none": "no claims: one process owns ai.db (better-sqlite3, single writer); a run is taken with a compare-and-set on its status (queued \u2192 running)"
}
},
"notes": "Providers, models, routing profiles, versioned prompt templates and workflows, runs, request logs, citations, scoped cache, quotas and audit. Workflow output is a draft/evidence package attributed to workflow + model + run. ai.run.queued|succeeded|failed|cached have payload contracts (v0.30.0, status planned): AI publishes them once its Events client is wired in; it publishes none today. ai.run.create takes ai.run-request@1 and returns ai.run@1."
ddiff --git a/package-lock.json b/package-lock.json
iindex c19262d..f02185b 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,9 +13,9 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.86.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.109.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -464,65 +464,6 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
-- "node_modules/base64-js": {
-- "version": "1.5.1",
-- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
-- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
-- "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",
-- "optional": true,
-- "peer": true
-- },
-- "node_modules/better-sqlite3": {
-- "version": "11.10.0",
-- "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
-- "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
-- "hasInstallScript": true,
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "bindings": "^1.5.0",
-- "prebuild-install": "^7.1.1"
-- }
-- },
-- "node_modules/bindings": {
-- "version": "1.5.0",
-- "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
-- "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "file-uri-to-path": "1.0.0"
-- }
-- },
-- "node_modules/bl": {
-- "version": "4.1.0",
-- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
-- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "buffer": "^5.5.0",
-- "inherits": "^2.0.4",
-- "readable-stream": "^3.4.0"
-- }
-- },
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@@ -547,32 +488,6 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
-- "node_modules/buffer": {
-- "version": "5.7.1",
-- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
-- "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
-- "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",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "base64-js": "^1.3.1",
-- "ieee754": "^1.1.13"
-- }
-- },
"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",
@@@ -617,14 +532,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/chownr": {
-- "version": "1.1.4",
-- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
-- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@@ -724,34 +631,6 @@
"ms": "2.0.0"
}
},
-- "node_modules/decompress-response": {
-- "version": "6.0.0",
-- "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
-- "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "mimic-response": "^3.1.0"
-- },
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/deep-extend": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
-- "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=4.0.0"
-- }
-- },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@@ -840,17 +719,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/end-of-stream": {
-- "version": "1.4.5",
-- "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
-- "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "once": "^1.4.0"
-- }
-- },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -896,17 +764,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/expand-template": {
-- "version": "2.0.3",
-- "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
-- "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
-- "license": "(MIT OR WTFPL)",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@@ -975,14 +832,6 @@
],
"license": "BSD-3-Clause"
},
-- "node_modules/file-uri-to-path": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
-- "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -1019,14 +868,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/fs-constants": {
-- "version": "1.0.0",
-- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
-- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@@ -1073,14 +914,6 @@
"node": ">= 0.4"
}
},
-- "node_modules/github-from-package": {
-- "version": "0.0.0",
-- "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
-- "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@@ -1149,42 +982,12 @@
"node": ">=0.10.0"
}
},
-- "node_modules/ieee754": {
-- "version": "1.2.1",
-- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-- "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": "BSD-3-Clause",
-- "optional": true,
-- "peer": true
-- },
"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/ini": {
-- "version": "1.3.8",
-- "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
-- "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/iovalkey": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
@@@ -1422,53 +1225,12 @@
"node": ">= 0.6"
}
},
-- "node_modules/mimic-response": {
-- "version": "3.1.0",
-- "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
-- "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=10"
-- },
-- "funding": {
-- "url": "https://github.com/sponsors/sindresorhus"
-- }
-- },
-- "node_modules/minimist": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
-- "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "funding": {
-- "url": "https://github.com/sponsors/ljharb"
-- }
-- },
-- "node_modules/mkdirp-classic": {
-- "version": "0.5.3",
-- "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
-- "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"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/napi-build-utils": {
-- "version": "2.0.0",
-- "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
-- "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -1478,20 +1240,6 @@
"node": ">= 0.6"
}
},
-- "node_modules/node-abi": {
-- "version": "3.96.0",
-- "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
-- "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "semver": "^7.3.5"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@@ -1516,21 +1264,10 @@
"node": ">= 0.8"
}
},
-- "node_modules/once": {
-- "version": "1.4.0",
-- "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-- "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "wrappy": "1"
-- }
-- },
"node_modules/openvibe-contracts": {
-- "version": "0.86.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.86.0",
-- "integrity": "sha512-3o3AILEBtQ2pK0KqK7GEAlN3HJjaKUl6vrbzEDiBbhK6Nr2hivgN/5MAmJ7WRkgaDkxNsZETs/fLZC2Q4oNCiA==",
++ "version": "0.109.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.109.0",
++ "integrity": "sha512-Ks2zLtR9XcN6ggr1APv93Ot8o/U1oAn8fOHNruYTDh1wjL/mKiwq5Lee8mx4rl6eb2A9zY8A45P/6edb3NNxgQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1573,9 +1310,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "2.5.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
-- "integrity": "sha512-fHuUS6SXM7ezatfAyqLm1V8B6puV9YXSnz6aG6XQbWx28qghRCCHeUF3lr4nxiCGdn9SyikYtIyLGL5r1ycp9Q==",
++ "version": "2.12.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
++ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
@@@ -1727,35 +1464,6 @@
"node": ">=0.10.0"
}
},
-- "node_modules/prebuild-install": {
-- "version": "7.1.3",
-- "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
-- "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
-- "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "detect-libc": "^2.0.0",
-- "expand-template": "^2.0.3",
-- "github-from-package": "0.0.0",
-- "minimist": "^1.2.3",
-- "mkdirp-classic": "^0.5.3",
-- "napi-build-utils": "^2.0.0",
-- "node-abi": "^3.3.0",
-- "pump": "^3.0.0",
-- "rc": "^1.2.7",
-- "simple-get": "^4.0.0",
-- "tar-fs": "^2.0.0",
-- "tunnel-agent": "^0.6.0"
-- },
-- "bin": {
-- "prebuild-install": "bin.js"
-- },
-- "engines": {
-- "node": ">=10"
-- }
-- },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -1773,18 +1481,6 @@
"url": "https://opencollective.com/express"
}
},
-- "node_modules/pump": {
-- "version": "3.0.4",
-- "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
-- "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "end-of-stream": "^1.1.0",
-- "once": "^1.3.1"
-- }
-- },
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@@ -1825,39 +1521,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/rc": {
-- "version": "1.2.8",
-- "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
-- "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
-- "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "deep-extend": "^0.6.0",
-- "ini": "~1.3.0",
-- "minimist": "^1.2.0",
-- "strip-json-comments": "~2.0.1"
-- },
-- "bin": {
-- "rc": "cli.js"
-- }
-- },
-- "node_modules/readable-stream": {
-- "version": "3.6.2",
-- "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
-- "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "inherits": "^2.0.3",
-- "string_decoder": "^1.1.1",
-- "util-deprecate": "^1.0.1"
-- },
-- "engines": {
-- "node": ">= 6"
-- }
-- },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
@@@ -2089,55 +1752,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
-- "node_modules/simple-concat": {
-- "version": "1.0.1",
-- "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
-- "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
-- "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",
-- "optional": true,
-- "peer": true
-- },
-- "node_modules/simple-get": {
-- "version": "4.0.1",
-- "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
-- "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
-- "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",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "decompress-response": "^6.0.0",
-- "once": "^1.3.1",
-- "simple-concat": "^1.0.0"
-- }
-- },
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
@@@ -2172,60 +1786,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/string_decoder": {
-- "version": "1.3.0",
-- "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
-- "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "safe-buffer": "~5.2.0"
-- }
-- },
-- "node_modules/strip-json-comments": {
-- "version": "2.0.1",
-- "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-- "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "engines": {
-- "node": ">=0.10.0"
-- }
-- },
-- "node_modules/tar-fs": {
-- "version": "2.1.5",
-- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
-- "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "chownr": "^1.1.1",
-- "mkdirp-classic": "^0.5.2",
-- "pump": "^3.0.0",
-- "tar-stream": "^2.1.4"
-- }
-- },
-- "node_modules/tar-stream": {
-- "version": "2.2.0",
-- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
-- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "bl": "^4.0.3",
-- "end-of-stream": "^1.4.1",
-- "fs-constants": "^1.0.0",
-- "inherits": "^2.0.3",
-- "readable-stream": "^3.1.1"
-- },
-- "engines": {
-- "node": ">=6"
-- }
-- },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -2242,20 +1802,6 @@
"license": "0BSD",
"optional": true
},
-- "node_modules/tunnel-agent": {
-- "version": "0.6.0",
-- "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-- "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
-- "license": "Apache-2.0",
-- "optional": true,
-- "peer": true,
-- "dependencies": {
-- "safe-buffer": "^5.0.1"
-- },
-- "engines": {
-- "node": "*"
-- }
-- },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -2278,14 +1824,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/util-deprecate": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-- "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
-- "license": "MIT",
-- "optional": true,
-- "peer": true
-- },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@@ -2304,14 +1842,6 @@
"node": ">= 0.8"
}
},
-- "node_modules/wrappy": {
-- "version": "1.0.2",
-- "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-- "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
-- "license": "ISC",
-- "optional": true,
-- "peer": true
-- },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
ddiff --git a/package.json b/package.json
iindex 2035993..db39459 100644
---- a/package.json
++++ b/package.json
@@@ -25,9 +25,9 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.86.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.109.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.5.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0"
},
"optionalDependencies": {
ddiff --git a/server/app.js b/server/app.js
iindex aacce5a..7309e03 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -13,6 +13,8 @@ const { createOps } = require('./ops');
const { consoleRouter } = require('./console');
const views = require('./console/views');
const cachePolicy = require('openvibe-shared/cache-policy');
++const ovServe = require('openvibe-shared/serve');
++const home = require('./home');
const pkg = require('../package.json');
function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys, env = process.env, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console, credentials = null }) {
@@@ -63,6 +65,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
res.setHeader('Cache-Control', 'no-store');
next();
});
++ // The product home's browser files (the OpenVibe Frame, openvibe-shared/serve) under content-addressed
++ // /shared/* URLs from this repo's pinned openvibe-shared; each answer sets its own asset cache policy.
++ app.use('/shared', ovServe.handler());
// Inline images arrive as data URLs; the per-run input cap is enforced again in runs.create.
app.use('/api', express.json({ limit: Math.ceil(config.runs.maxInputBytes * 1.5), type: ['application/json', 'application/*+json'] }));
@@@ -113,12 +118,30 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
} catch (e) { next(e); }
});
-- // Crawlers: nothing here is for search engines, the console and sign-in least of all (no sitemap either).
++ // Discovery for the public home (ai.openvibe.services): the home and the price page are for search engines
++ // and assistants; the console, its sign-in and the API are not.
app.get('/robots.txt', (_req, res) => {
-- res.type('text/plain').send('User-agent: *\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n');
++ res.type('text/plain').send(`User-agent: *\nAllow: /$\nAllow: /stats\nDisallow: /console\nDisallow: /auth/\nDisallow: /api/\n\nSitemap: ${config.baseUrl}/sitemap.xml\n`);
++ });
++ app.get('/sitemap.xml', (_req, res) => {
++ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${['/', '/stats'].map((p) => ` <url><loc>${config.baseUrl}${p}</loc></url>`).join('\n')}\n</urlset>\n`);
++ });
++ app.get('/llms.txt', (_req, res) => {
++ res.setHeader('Cache-Control', 'public, max-age=3600');
++ res.type('text/plain').send(home.llmsTxt({ siteUrl: config.baseUrl }));
});
-- app.get('/', (_req, res) => {
++ // GET /: the home page for a browser (server/home.js), the API index for curl and API clients (`*/*`, no
++ // Accept, or any non-HTML Accept). Vary: Accept so a shared cache never serves one to the other; the home is
++ // private because Cloudflare caches by URL and ignores Vary: Accept. The home carries its own CSP.
++ app.get('/', (req, res) => {
++ res.vary('Accept');
++ if (req.accepts(['text/plain', 'text/html']) === 'text/html') {
++ return res.type('html')
++ .set('Content-Security-Policy', home.HOME_CSP)
++ .set('Cache-Control', 'private, max-age=300')
++ .send(home.renderHome({ siteUrl: config.baseUrl }));
++ }
res.type('text/plain').send([
'OpenVibe.AI: providers, models, routing, versioned prompt templates and workflows, runs, citations, scoped cache, quotas and audit.',
'',
@@@ -130,7 +153,9 @@ function createApp({ config, db, registry, pool, quotas, cache, runs, auth, keys
'GET /stats price and latency per provider and model (public)',
'GET /console the operator console (OpenVibe.Network staff sign-in)',
'',
-- 'Callers authenticate with OpenVibe.Network service tokens (audience openvibe.ai).',
++ 'Callers authenticate with OpenVibe.Network tokens (audience openvibe.ai): first-party service tokens, or a',
++ 'developer app\'s token (ai.app.run) on the six direct operations and GET /api/v1/runs/:id.',
++ 'Developer apps: https://openvibe.codes/projects',
'AI output is a draft/evidence package attributed to a workflow, model and run, never to a person.',
'',
'Source: https://github.com/OpenVibers/OpenVibe.AI',
ddiff --git a/server/auth.js b/server/auth.js
iindex c14eeab..80e7383 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -7,12 +7,8 @@
* exponential backoff, and a rotation honoured on an unknown kid. OV_NETWORK_PUBLIC_KEY still pins a
* PEM, which skips the fetch entirely.
*
-- * Capability ids are decided by openvibe-contracts when it knows them (the release named below);
-- * until then allows() decides with the contracts' own grant rule (the exact id, or a `family.*`
-- * grant), but only for the ids PROPOSED names — a proposal this service enforces ahead of the
-- * release that registers it (docs/capabilities-proposal/, the pattern OpenVibe.Events used before
-- * events.app.* shipped). ai.app.run (T6 step 10: developer apps, ADR-014) needs exactly that
-- * fallback until openvibe-contracts knows the id.
++ * Capability ids are decided by openvibe-contracts: an id the pinned release does not know is refused,
++ * never silently allowed (ai.app.run, developer apps, is registered since openvibe-contracts 0.109.0).
*
* Namespaces fail closed: a service token may only run workflows inside the namespaces its `ns`
* claim holds (the same matching as contracts' namespaceAllowed: 'live.*' allows 'live.translate').
@@@ -40,9 +36,6 @@ const CAPS = Object.freeze({
appRun: 'ai.app.run',
});
--/** Ids enforced here before the pinned openvibe-contracts release knows them (docs/capabilities-proposal/). */
--const PROPOSED = Object.freeze(['ai.app.run']);
--
/**
* The Network signing keys, through the SDK's process-wide JWKS client (one per URL). A pinned
* OV_NETWORK_PUBLIC_KEY PEM skips the fetch entirely; /api/ready reports either as ready.
@@@ -64,12 +57,7 @@ function hasCap(claims, id) {
function allows(claims, id) {
if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
-- if (!capabilities.get(id)) {
-- // Not in the pinned contracts release yet: only a PROPOSED id is enforced (by the same exact-id-or-family
-- // grant rule hasCap applied); any other unknown id is refused, never silently allowed.
-- if (!PROPOSED.includes(id)) return { allowed: false, code: 'capability.denied', reason: `${id} is not a capability this release knows` };
-- return { allowed: true, code: null, reason: null };
-- }
++ if (!capabilities.get(id)) return { allowed: false, code: 'capability.denied', reason: `${id} is not a capability this release knows` };
const c = capabilities.check(claims, id);
return c;
}
@@@ -200,4 +188,4 @@ function createAuth({ config, log = console }) {
return { verify, requireCap, appOrCap, principalHas };
}
--module.exports = { CAPS, PROPOSED, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
++module.exports = { CAPS, createNetworkKeys, createAuth, hasCap, allows, isAppClaims, namespaceAllowed, effectiveNamespaces, principalSubject, bearer };
ddiff --git a/server/config.js b/server/config.js
iindex d33e832..390dbbb 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -39,7 +39,7 @@ function load(env = process.env) {
if (env.AI_PRICING_JSON) {
try { pricing = JSON.parse(env.AI_PRICING_JSON) || {}; } catch { throw new Error('AI_PRICING_JSON is not valid JSON'); }
}
-- const baseUrl = trimUrl(env.BASE_URL || (isProduction ? 'https://ai.openvibe.network' : `http://localhost:${port}`));
++ const baseUrl = trimUrl(env.BASE_URL || (isProduction ? 'https://ai.openvibe.services' : `http://localhost:${port}`));
return {
port,
host: env.HOST || '127.0.0.1',
ddiff --git a/server/console/index.js b/server/console/index.js
iindex 89809c4..09549f6 100644
---- a/server/console/index.js
++++ b/server/console/index.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* The AI operator console (roadmap WS-O task 4): server-rendered, no JavaScript, on
-- * ai.openvibe.network under /console, for OpenVibe.Network staff.
++ * ai.openvibe.services under /console, for OpenVibe.Network staff.
*
* GET /auth/login, /auth/callback Network SSO (authorization code + PKCE S256, OAuth client `ai`)
* POST /auth/logout
ddiff --git a/server/fetcher.js b/server/fetcher.js
iindex 101ee2a..fc140c4 100644
---- a/server/fetcher.js
++++ b/server/fetcher.js
@@@ -119,7 +119,7 @@ function createFetcher(config, { transport = null } = {}) {
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: 'GET',
-- headers: { 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.network)', Accept: '*/*' },
++ headers: { 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.services)', Accept: '*/*' },
// Internal Media is operator-configured (usually loopback); everything else must be public.
lookup: internal ? undefined : safeLookup,
timeout: timeoutMs,
@@@ -186,7 +186,7 @@ function createFetcher(config, { transport = null } = {}) {
const mod = url.protocol === 'https:' ? https : http;
const req = mod.request(url, {
method: method === 'HEAD' ? 'HEAD' : 'GET',
-- headers: { 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.network)', Accept: '*/*', ...(range ? { Range: range } : {}) },
++ headers: { 'User-Agent': 'OpenVibe.AI/0.1 (+https://ai.openvibe.services)', Accept: '*/*', ...(range ? { Range: range } : {}) },
lookup: internal ? undefined : safeLookup,
timeout: timeoutMs,
});
ddiff --git a/server/home.js b/server/home.js
nnew file mode 100644
iindex 0000000..4b62c13
---- /dev/null
++++ b/server/home.js
@@@ -0,0 +1,161 @@
++'use strict';
++
++/**
++ * The product home of OpenVibe.AI (ai.openvibe.services, plan T6: AI as a developer product). The OpenVibe Frame
++ * (openvibe-shared/shell) around openvibe-shared/showcase sections for developers. Every claim restates the README
++ * (API, Developer apps, Providers, Local model) and the code; nothing here reads data. Prices and measured latency
++ * are the /stats page, which reads them.
++ *
++ * renderHome({ siteUrl }) the whole document (siteUrl: the public origin, config.baseUrl)
++ * HOME_CSP its Content-Security-Policy (the Frame's scripts and calls to the Network)
++ * llmsTxt({ siteUrl }) /llms.txt, the same facts as plain text
++ */
++const shell = require('openvibe-shared/shell');
++const showcase = require('openvibe-shared/showcase');
++const ovServe = require('openvibe-shared/serve');
++const appIcon = require('openvibe-shared/app-icon');
++
++const SITE_NAME = 'OpenVibe.AI';
++const NETWORK_URL = 'https://openvibe.network';
++const PROJECTS_URL = 'https://openvibe.codes/projects';
++const API_DOCS_URL = 'https://openvibe.codes/docs/api';
++const SOURCE = 'https://github.com/OpenVibers/OpenVibe.AI';
++const GUIDE = `${SOURCE}#developer-apps`;
++const DESCRIPTION = 'One API for chat, generation, summaries, classification, extraction and embeddings across many models. '
++ + 'Every run says which model answered, why it was chosen and what it cost, and sandbox apps run on free and local capacity.';
++
++const HOME_CSP = [
++ "default-src 'self'",
++ "script-src 'self' 'unsafe-inline' https://openvibe.network",
++ "style-src 'self' 'unsafe-inline' https://openvibe.network https://fonts.googleapis.com https://cdnjs.cloudflare.com",
++ "font-src 'self' data: https://fonts.gstatic.com https://cdnjs.cloudflare.com",
++ "img-src 'self' data: https:",
++ "connect-src 'self' https://openvibe.network",
++ "frame-src 'self' https://openvibe.network",
++ "frame-ancestors 'none'",
++ "object-src 'none'",
++ "base-uri 'self'",
++ "form-action 'self' https://openvibe.network",
++].join('; ');
++
++function sections({ siteUrl }) {
++ const token = [
++ 'TOKEN=$(curl -s -X POST https://openvibe.network/oauth/token \\',
++ ' -d grant_type=client_credentials -d client_id=$APP -d client_secret=$SECRET \\',
++ ' -d audience=openvibe.ai | jq -r .access_token)',
++ '# a five-minute token for your app (app_<ULID>), scoped to its project',
++ ].join('\n');
++ const chat = [
++ `curl -s -X POST ${siteUrl}/api/v1/chat \\`,
++ ' -H "Authorization: Bearer $TOKEN" -H \'Content-Type: application/json\' \\',
++ ' -d \'{ "messages": [ { "role": "user", "content": "Name three uses for a paperclip." } ] }\'',
++ '# → { "run": { "id": "run_…", "status": "succeeded", "output": …, "provenance": …, "explain": … } }',
++ ].join('\n');
++ const read = [
++ `curl -s ${siteUrl}/api/v1/runs/$RUN -H "Authorization: Bearer $TOKEN" | jq .run.explain`,
++ '# objective, the provider and model selected, and every candidate with its',
++ '# estimated cost, latency and the reason it was passed over',
++ ].join('\n');
++ return showcase.hero({
++ eyebrow: 'OpenVibe.AI · alpha',
++ title: 'Many models, one API.', accent: 'Every answer explained.',
++ lede: 'Chat, generate, summarize, classify, extract and embed through one API. OpenVibe.AI picks a model by price, measured latency '
++ + 'and health, falls back when a provider fails, and tells you on every run which model answered, why it was chosen and what it cost.',
++ actions: [{ label: 'Start building', href: PROJECTS_URL, primary: true }, { label: 'See prices and latency', href: '/stats' }],
++ note: 'Alpha. OpenVibe.Live and OpenVibe.Network already run their AI here in production; developer apps start in the sandbox, on free and local capacity.',
++ }) + showcase.features({
++ title: 'What you get',
++ items: [
++ { icon: 'ov:text', title: 'Six operations', text: 'Chat, generate, summarize, classify, extract and embed, with structured JSON output when you ask for a schema.' },
++ { icon: 'ov:map', title: 'Routing you can read', text: 'Every run carries an explanation: what it optimised for, the model it picked, and every other candidate with its estimated cost and latency.' },
++ { icon: 'ov:history', title: 'Fallbacks on the record', text: 'A provider that is down, out of credentials or too slow is skipped and logged. When a fallback answers, the run says so.' },
++ { icon: 'ov:host', title: 'Free and local first', text: 'A small open model runs on OpenVibe\'s own server, beside free providers. Sandbox apps use only that capacity, so trying it costs nothing.' },
++ { icon: 'ov:account', title: 'Your project\'s runs', text: 'Runs belong to your project: another project can never read them. Usage is metered per project, with a free allowance first.' },
++ { icon: 'ov:code', title: 'Open source', text: 'AGPL-3.0, built in the open. Every guarantee on this page has a test in the repository.' },
++ ],
++ }) + showcase.code({
++ id: 'examples',
++ title: 'Token, chat, explain',
++ lede: 'With an app from OpenVibe.Codes: get a token from OpenVibe.Network, call an operation, then read why that model answered.',
++ samples: [
++ { label: 'Token', lang: 'bash', code: token },
++ { label: 'Chat', lang: 'bash', code: chat },
++ { label: 'Explain', lang: 'bash', code: read },
++ ],
++ }) + showcase.steps({
++ title: 'Start in four steps',
++ items: [
++ { title: 'Create a project', text: 'Sign in at openvibe.codes/projects with your OpenVibe account and create a project.' },
++ { title: 'Add an app', text: 'Add an app to it. A confidential app gets a client id and a secret, shown once.' },
++ { title: 'Get a token', text: 'Exchange them at OpenVibe.Network for a five-minute token with the audience openvibe.ai.' },
++ { title: 'Call an operation', text: 'POST to /api/v1/chat or any of the other five, and keep the run id to read it later.' },
++ ],
++ }) + showcase.limits({
++ title: 'Sandbox and production',
++ lede: 'Both environments call the same API; the token says which one an app is in.',
++ columns: ['Sandbox', 'Production'],
++ rows: [
++ { label: 'Access to ai.app.run', values: ['Every sandbox app, without asking', 'Once OpenVibe staff add it to the project'] },
++ { label: 'Models', values: ['Free and local capacity only', 'Free and local capacity; metered models under a project budget'] },
++ { label: 'Free allowance', values: ['Per project, per provider', 'Per project, per provider'] },
++ { label: 'Who can read a run', values: ['The project that made it', 'The project that made it'] },
++ { label: 'A person\'s own provider key', values: ['Never used', 'Never used'] },
++ ],
++ }) + showcase.nextSteps({
++ items: [
++ { what: 'Prices and latency', text: 'Measured per provider and model over the last seven days.', href: '/stats' },
++ { what: 'Create a project and an app', text: 'Sign in with your OpenVibe account; the sandbox is open to every project.', href: PROJECTS_URL, to: 'codes' },
++ { what: 'API reference', text: 'The OpenAPI document generated from the published contracts.', href: API_DOCS_URL, to: 'codes' },
++ { what: 'Developer guide', text: 'Tokens, limits, billing and the sandbox, in full.', href: GUIDE },
++ ],
++ });
++}
++
++function renderHome({ siteUrl }) {
++ const navLinks = [{ label: 'Prices', href: '/stats' }, { label: 'Guide', href: GUIDE }];
++ const nav = { service: 'ai', apiBase: NETWORK_URL, links: navLinks, history: { type: 'page', title: SITE_NAME } };
++ const footer = { service: 'ai', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
++ return shell.page({
++ name: SITE_NAME, service: 'ai', lang: 'en',
++ title: `${SITE_NAME}: many models, one API, every answer explained`,
++ siteName: SITE_NAME,
++ description: DESCRIPTION,
++ summary: DESCRIPTION,
++ canonical: `${siteUrl}/`,
++ robots: 'index, follow',
++ navbar: nav, footer, home: '/', navLinks,
++ head: [
++ appIcon.headTags({ site: 'ai' }),
++ `<link rel="stylesheet" href="${ovServe.url(showcase.STYLESHEET)}">`,
++ ].join('\n'),
++ body: `<div id="navbar-mount"></div>
++<main id="main" class="page">
++${sections({ siteUrl })}
++</main>`,
++ });
++}
++
++function llmsTxt({ siteUrl }) {
++ return [
++ `# ${SITE_NAME}`,
++ '',
++ `> ${DESCRIPTION}`,
++ '',
++ `- Home: ${siteUrl}/`,
++ `- Prices and measured latency per provider and model: ${siteUrl}/stats`,
++ `- Developer guide (tokens, limits, billing, sandbox): ${GUIDE}`,
++ `- API reference (OpenAPI, generated from the contracts): ${API_DOCS_URL}`,
++ `- Create a project and an app: ${PROJECTS_URL}`,
++ `- Source (AGPL-3.0): ${SOURCE}`,
++ '',
++ '## Developer apps',
++ '',
++ '- Token: POST https://openvibe.network/oauth/token with grant_type=client_credentials, your app\'s client id and secret, audience=openvibe.ai (five minutes).',
++ `- Operations: POST ${siteUrl}/api/v1/chat, /generate, /summarize, /classify, /extract, /embed; GET ${siteUrl}/api/v1/runs/:id for your own runs.`,
++ '- Every run response carries explain: the objective, the model selected and every candidate with its estimated cost, latency and excluded reason.',
++ '- Sandbox apps run on free and local capacity only; runs, usage and the free allowance are per project; a person\'s own provider key is never used for an app.',
++ '',
++ ].join('\n');
++}
++
++module.exports = { renderHome, llmsTxt, HOME_CSP, SITE_NAME };
ddiff --git a/test/console.test.js b/test/console.test.js
iindex 62dd950..313b85a 100644
---- a/test/console.test.js
++++ b/test/console.test.js
@@@ -487,17 +487,18 @@ t.test('sign out revokes the session', async () => {
for (const s of SECRETS) assert.ok(!JSON.stringify(await h.db.prepare('SELECT * FROM audit_log').all()).includes(s), 'no secret in the audit log');
});
--t.test('robots.txt keeps crawlers out of the console and sign-in; there is no sitemap', async () => {
++t.test('robots.txt keeps crawlers out of the console and sign-in; the sitemap lists only public pages', async () => {
const r = await request(h.base, 'GET', '/robots.txt');
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('Disallow: /console') && r.text.includes('Disallow: /auth/'));
-- assert.ok(!/sitemap/i.test(r.text));
-- assert.strictEqual((await request(h.base, 'GET', '/sitemap.xml')).status, 404);
++ const map = await request(h.base, 'GET', '/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ assert.ok(!/\/console|\/auth\//.test(map.text), 'the console and sign-in are never in the sitemap');
assert.ok((await request(h.base, 'GET', '/')).text.includes('/console'));
});
t.test('the nginx vhost proxies /console, /auth/ and /robots.txt with the client address from $remote_addr only', () => {
-- const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'ai.openvibe.network.conf'), 'utf8');
++ const conf = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'ai.openvibe.services.conf'), 'utf8');
const active = conf.split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
for (const loc of ['location /console', 'location /auth/', 'location = /robots.txt']) {
const i = active.indexOf(`${loc} {`);
ddiff --git a/test/home.test.js b/test/home.test.js
nnew file mode 100644
iindex 0000000..b89a2fc
---- /dev/null
++++ b/test/home.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++// The public home (plan T6: AI as a developer product). ai.openvibe.services renders server/home.js for a
++// browser and keeps the text/plain API index for curl and API clients, serves the pinned OpenVibe Frame at
++// /shared, and answers robots.txt, sitemap.xml and llms.txt. The vhosts: the developer-app routes are the only
++// API the public vhost proxies, and ai.openvibe.network is a 301 to the product origin.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, request, suite } = require('./helpers');
++const { HOME_CSP } = require('../server/home');
++
++const SITE = 'https://ai.openvibe.services';
++const BROWSER = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8';
++const t = suite('home');
++let h;
++
++t.test('boot with the public origin as BASE_URL', async () => {
++ h = await boot({ env: { BASE_URL: SITE } });
++});
++
++t.test('GET / as a browser: the home page, its CSP and a private cache', async () => {
++ const r = await request(h.base, 'GET', '/', { headers: { Accept: BROWSER } });
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/html/);
++ assert.strictEqual(r.headers.get('content-security-policy'), HOME_CSP);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, max-age=300', 'Cloudflare ignores Vary: Accept');
++ assert.match(r.headers.get('vary'), /Accept/);
++ assert.strictEqual((r.text.match(/<h1\b/g) || []).length, 1, 'exactly one h1');
++ assert.ok(r.text.includes('Many models, one API.'), 'the hero');
++ assert.ok(r.text.includes(`<link rel="canonical" href="${SITE}/">`), 'the canonical public origin');
++ assert.ok(r.text.includes(`${SITE}/api/v1/chat`), 'the examples call the public origin');
++ assert.ok(r.text.includes('https://openvibe.codes/projects'), 'where a project and an app are made');
++ assert.ok(!/ai\.openvibe\.network/.test(r.text), 'never the old name');
++});
++
++t.test('GET / for curl and API clients: the text index, unchanged in kind', async () => {
++ for (const accept of [undefined, '*/*', 'application/json', 'text/plain']) {
++ const r = await request(h.base, 'GET', '/', { headers: accept ? { Accept: accept } : {} });
++ assert.strictEqual(r.status, 200, String(accept));
++ assert.match(r.headers.get('content-type'), /^text\/plain/, String(accept));
++ assert.ok(r.text.startsWith('OpenVibe.AI: providers, models, routing'), String(accept));
++ assert.ok(r.text.includes('ai.app.run') && r.text.includes('/console'), String(accept));
++ }
++});
++
++t.test('the home links the pinned Frame stylesheet, and /shared serves it', async () => {
++ const r = await request(h.base, 'GET', '/', { headers: { Accept: BROWSER } });
++ const href = (r.text.match(/href="(\/shared\/showcase\.css\?v=[0-9a-f]+)"/) || [])[1];
++ assert.ok(href, 'the showcase stylesheet, content-addressed');
++ const css = await request(h.base, 'GET', href);
++ assert.strictEqual(css.status, 200);
++ assert.match(css.headers.get('content-type'), /text\/css/);
++ assert.match(css.headers.get('cache-control'), /immutable/);
++});
++
++t.test('robots.txt, sitemap.xml and llms.txt name the public origin', async () => {
++ const robots = await request(h.base, 'GET', '/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ for (const line of ['Allow: /$', 'Allow: /stats', 'Disallow: /console', 'Disallow: /auth/', 'Disallow: /api/', `Sitemap: ${SITE}/sitemap.xml`]) assert.ok(robots.text.includes(line), line);
++ const map = await request(h.base, 'GET', '/sitemap.xml');
++ assert.strictEqual(map.status, 200);
++ assert.match(map.headers.get('content-type'), /xml/);
++ assert.deepStrictEqual([...map.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]), [`${SITE}/`, `${SITE}/stats`]);
++ const llms = await request(h.base, 'GET', '/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.match(llms.headers.get('content-type'), /^text\/plain/);
++ assert.ok(llms.text.startsWith('# OpenVibe.AI'));
++ assert.ok(llms.text.includes(`${SITE}/stats`) && llms.text.includes(`POST ${SITE}/api/v1/chat`));
++});
++
++const active = (file) => fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', file), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l)).join('\n');
++
++t.test('the public vhost proxies the home, its files and exactly the developer-app API routes', () => {
++ const conf = active('ai.openvibe.services.conf');
++ for (const loc of ['location = / {', 'location = /stats', 'location = /sitemap.xml', 'location = /llms.txt', 'location /shared/']) assert.ok(conf.includes(loc), loc);
++ const ops = conf.indexOf('location ~ ^/api/v1/(chat|generate|summarize|classify|extract|embed)$ {');
++ const run = conf.indexOf('location ~ ^/api/v1/runs/[A-Za-z0-9_-]+$ {');
++ assert.ok(ops >= 0 && run >= 0, 'the six operations and the run read');
++ for (const [i, verb] of [[ops, 'POST'], [run, 'GET']]) {
++ const block = conf.slice(i, conf.indexOf('\n }', i));
++ assert.ok(block.includes(`limit_except ${verb} { deny all; }`), verb);
++ assert.ok(block.includes('limit_req zone=ovai_apps'), 'rate-limited per address');
++ for (const hdr of ['X-Real-IP $remote_addr', 'X-Forwarded-For $remote_addr', 'CF-Connecting-IP $remote_addr']) assert.ok(block.includes(hdr), hdr);
++ }
++ const rest = conf.indexOf('location /api/ {');
++ assert.ok(conf.slice(rest, conf.indexOf('}', rest)).includes('return 403;'), 'the rest of the API stays host-local');
++ assert.ok(!conf.includes('$proxy_add_x_forwarded_for'));
++});
++
++t.test('ai.openvibe.network answers 301 to the same path on ai.openvibe.services, and proxies nothing', () => {
++ const conf = active('ai.openvibe.network.conf');
++ assert.ok(!conf.includes('proxy_pass'), 'nothing is served under the old name');
++ assert.strictEqual((conf.match(/return 301 https:\/\/ai\.openvibe\.services\$request_uri;/g) || []).length, 2, 'HTTP and HTTPS');
++});
++
++t.test('shutdown', async () => { await h.stop(); });
++
++t.run();