openvibers/openvibe.examples Commits

The developer path runs the AI example too: an app token for openvibe.ai (ai.app.run) and the official openai client on OpenVibe.AI's OpenAI-compatible endpoint, one short answer with its run id (#35)

OP OpenVibers committed 21 days ago · 96ee716ade · 4 files, +81 −32

Step 8 of 12. The mock stands in for AI's /v1/chat/completions in OpenAI's shapes and gets the
contract's public capabilities (its own default list predates ai.app.run), as Network reads them.

Changes

ddiff --git a/README.md b/README.md
iindex 5f58ecd..acf7435 100644
---- a/README.md
++++ b/README.md
@@@ -7,9 +7,9 @@
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
--`npm run developer-path` is the Wave 20 exit check (account to Media and Events to revoked
--credentials); CI runs it against the mock platform, and a person runs it against production.
--**Built on:** [openvibe-sdk v0.62.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.60.0), and
++`npm run developer-path` is the Wave 20 exit check (account to Media, Events and AI to revoked
++credentials); CI runs it against the mock platform, and OpenVibe.Host runs it against production daily.
++**Built on:** [openvibe-sdk v0.62.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.62.0), and
openvibe-contracts v0.209.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
@@@ -157,8 +157,9 @@ Verified in production on 2026-09-23 with a brand-new account, using the public
## Developer path check
`scripts/developer-path.js` is the roadmap Wave 20 exit check as a repeatable script: a developer
--goes from an account to a working Media and Events integration with public endpoints, the SDK and
--scoped credentials only, then rotates and revokes those credentials and cleans up.
++goes from an account to a working Media, Events and AI integration with public endpoints, the SDK and
++scoped credentials only, then rotates and revokes those credentials and cleans up. OpenVibe.Host runs it
++against production every day (`openvibe-devpath.timer`, shown on openvibe.network/status).
| Step | What it does |
|---|---|
@@@ -166,19 +167,22 @@ scoped credentials only, then rotates and revokes those credentials and cleans u
| 2. discovery | reads `/.well-known/openvibe` as any origin may |
| 3. project | creates a sandbox project with `openvibe-sdk/projects` |
| 4. app | creates a confidential sandbox app; its secret stays in memory |
--| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `services.release.manage`; all must be approved at once |
++| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `ai.app.run`, `services.release.manage`; all must be approved at once |
| 6. media | [media-uploader](examples/media-uploader): upload a small file, read it back, delete it |
| 7. events | [event-subscriber](examples/event-subscriber): publish `app.<project_key>.developer_path.ran`, pull it with a cursor |
--| 8. export | as the project's owner: Network mints 5-minute read-only export tokens; Media lists the project's sandbox files with one and refuses an upload; Events returns the event from step 7 with the other |
--| 9. release | as the app, on OpenVibe.Services (`OV_SERVICES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |
--| 10. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
--| 11. cleanup | archives the project, even when an earlier step failed; the app's secret must then be refused |
++| 8. ai | [ai-chat](examples/ai-chat): the official `openai` client on OpenVibe.AI's OpenAI-compatible endpoint (`OV_AI_URL`) with an app token for `openvibe.ai`; one short answer, which must carry its `x_openvibe` run id |
++| 9. export | as the project's owner: Network mints 5-minute read-only export tokens; Media lists the project's sandbox files with one and refuses an upload; Events returns the event from step 7 with the other |
++| 10. release | as the app, on OpenVibe.Services (`OV_SERVICES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |
++| 11. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
++| 12. cleanup | archives the project, even when an earlier step failed; the app's secret must then be refused |
**In CI**, on every push, `npm run developer-path:mock` runs the same code against
`openvibe-sdk/testing`'s mock platform (the `developer-path` job; `test/developer-path.test.js`
also checks it leaves nothing behind, never prints a secret, and still cleans up after a failure).
The mock has no account API, so `scripts/developer-path-mock.js` adds register and login routes
--that mint mock user tokens, mints export tokens, and stands in for Services' release API; every other step is the platform mock's own.
++that mint mock user tokens, mints export tokens, and stands in for Services' release API and AI's
++`/v1/chat/completions`; it hands the mock the contract's public capabilities, as Network reads them. Every
++other step is the platform mock's own.
**Against production** it runs only when a person starts it. Credentials come from the
environment (or `./.env`) only:
@@@ -194,7 +198,7 @@ It refuses to start without credentials, and refuses in CI against the productio
set and `OV_NETWORK_URL` unset or `https://openvibe.network`), so it can later run in CI against
an integration environment but never against production by accident. It masks every password,
secret and token in its output and prints signed URLs without their signature. Exit `0` when all
--eleven steps passed, `1` when one failed, `2` when it refused to start.
++twelve steps passed, `1` when one failed, `2` when it refused to start.
It does not yet cover webhook delivery to an external endpoint (that needs a public https host)
or publishing an app release in OpenVibe.Services.
@@@ -315,7 +319,7 @@ Examples implements no capability (its manifest lists none). The examples call t
developer app with the grants a sandbox project asks for (`npm run new-app`): `media.object.upload`
and `media.object.read` (Media), `events.app.publish`, `events.app.read` and `events.app.subscribe`
(Events), and `tools.job.create`, `tools.job.read` and `tools.job.cancel` (Tools); a public browser app
--gets `media.object.read` only. The developer path check also uses `services.release.manage` (Services). The
++gets `media.object.read` only. The developer path check also uses `ai.app.run` (AI) and `services.release.manage` (Services). The
chat bot uses a Live `hbt_` token with scope `chat`, because `chat.message.send` is first-party.
## Acceptance
ddiff --git a/STATUS.json b/STATUS.json
iindex 84357ec..227ea9f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -13,7 +13,7 @@
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
-- "developer path check (mock in CI, real on demand)",
++ "developer path check: account → project → app → Media, Events and AI → release → rotated and revoked credentials (mock in CI, production daily from OpenVibe.Host)",
"tools-job-proof: the scheduled Tools job proof (submit, stream resume with Last-Event-ID, Media result, Events trail)",
"public-surface check: no loopback keys, internal routes or secret values"
],
ddiff --git a/scripts/developer-path-mock.js b/scripts/developer-path-mock.js
iindex 2a676b1..c43c4d0 100644
---- a/scripts/developer-path-mock.js
++++ b/scripts/developer-path-mock.js
@@@ -13,14 +13,21 @@
* platform like any app token) and stands in for OpenVibe.Services' release API (the release step):
* an app token for openvibe.services carrying services.release.manage manages the app's own releases,
* draft → published → deprecated → revoked, and the public list shows every release but drafts, like Services.
++ * And it stands in for OpenVibe.AI's OpenAI-compatible endpoint (the ai step): an app token for openvibe.ai carrying
++ * ai.app.run gets a chat.completion with x_openvibe, in OpenAI's shapes, errors included.
* Everything else is the platform mock's own.
*/
const { createMockPlatform } = require('openvibe-sdk/testing');
--const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_SERVICES } = require('./developer-path');
++const contracts = require('openvibe-contracts');
++const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI } = require('./developer-path');
function withAccounts(platform, network = PRODUCTION_NETWORK) {
const byName = new Map();
const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ // A token's claims (its signature is the platform's; here the claims decide), or null.
++ const claimsOf = (authorization) => {
++ try { return JSON.parse(Buffer.from(String(authorization || '').replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8')); } catch { return null; }
++ };
// OpenVibe.Services' release API, in memory (the token's signature is the platform's; here its claims decide).
const releases = new Map();
let n = 0;
@@@ -29,8 +36,7 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
if (!m) return reply(404, { code: 'route.not_found' });
const list = (appId) => [...releases.values()].filter((r) => r.app_id === appId && r.status !== 'draft');
if (m[1] && method === 'GET') return reply(200, { app_id: m[1], releases: list(m[1]) });
-- let claims = null;
-- try { claims = JSON.parse(Buffer.from(String((init.headers || {}).Authorization || '').replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8')); } catch { claims = null; }
++ const claims = claimsOf((init.headers || {}).Authorization);
if (!claims) return reply(401, { code: 'auth.required' });
if (!(claims.aud || []).includes('openvibe.services') || !(claims.cap || []).includes('services.release.manage')) return reply(403, { code: 'capability.denied' });
const body = JSON.parse(init.body || '{}');
@@@ -48,6 +54,25 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
return reply(200, { release: rel });
}
++ // OpenVibe.AI's /v1/chat/completions (server/api/openai.js), in OpenAI's shapes.
++ let runs = 0;
++ async function ai(url, method, init) {
++ const fail = (status, code, message) => reply(status, { error: { message, type: 'invalid_request_error', code } });
++ if (url.pathname !== '/v1/chat/completions' || method !== 'POST') return fail(404, 'route.not_found', 'no such route');
++ const headers = new Headers(init.headers || {});
++ const claims = claimsOf(headers.get('authorization'));
++ if (!claims) return fail(401, 'token.missing', 'an app token for openvibe.ai is required');
++ if (!(claims.aud || []).includes('openvibe.ai') || !(claims.cap || []).includes('ai.app.run')) return fail(403, 'capability.denied', 'ai.app.run is required');
++ const body = JSON.parse(typeof init.body === 'string' ? init.body : await new Response(init.body).text());
++ if (!Array.isArray(body.messages) || !body.messages.length) return fail(400, 'request.invalid', 'messages is required');
++ return reply(200, {
++ id: `chatcmpl-mock${++runs}`, object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: 'mock-local',
++ choices: [{ index: 0, message: { role: 'assistant', content: 'Ready.' }, finish_reason: 'stop' }],
++ usage: { prompt_tokens: 6, completion_tokens: 2, total_tokens: 8 },
++ x_openvibe: { run_id: `run_mock${runs}`, provider: 'local', fallback_used: false, cost_usd: 0 },
++ });
++ }
++
return async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url);
const method = (init.method || (typeof input === 'string' ? 'GET' : input.method) || 'GET').toUpperCase();
@@@ -74,13 +99,18 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
return reply(201, { access_token: token, token_type: 'Bearer', expires_in: 300, scope: caps.join(' ') });
}
if (url.origin === PRODUCTION_SERVICES) return services(url, method, init);
++ if (url.origin === PRODUCTION_AI) return ai(url, method, init);
return platform.fetch(input, init);
};
}
++// The capabilities apps may ask for, as Network reads them: the contract's public, active ones (the mock's own
++// default list predates ai.app.run).
++const APP_CAPABILITIES = contracts.capabilities.manifests.filter((c) => c.visibility === 'public' && (c.status || 'active') === 'active');
++
/** → { result, platform, output }. platformOptions go to createMockPlatform (tests use them to break a step). */
async function runAgainstMock({ log, platformOptions = {} } = {}) {
-- const platform = createMockPlatform(platformOptions);
++ const platform = createMockPlatform({ capabilities: APP_CAPABILITIES, ...platformOptions });
const lines = [];
const result = await runDeveloperPath({
network: PRODUCTION_NETWORK,
ddiff --git a/scripts/developer-path.js b/scripts/developer-path.js
iindex ca2db98..c843b6d 100644
---- a/scripts/developer-path.js
++++ b/scripts/developer-path.js
@@@ -2,27 +2,29 @@
'use strict';
/**
* npm run developer-path: the roadmap Wave 20 exit check, repeatable. A developer goes from an
-- * account to a working Media and Events integration with public endpoints, the SDK and scoped
++ * account to a working Media, Events and AI integration with public endpoints, the SDK and scoped
* credentials only, then rotates and revokes those credentials and archives the project:
*
* 1. account register (--register) or sign in at <network>/api/auth/*, or OV_USER_TOKEN
* 2. discovery GET /.well-known/openvibe, as any origin may
* 3. project a new project (sandbox) through /api/v1/projects (openvibe-sdk/projects)
* 4. app a confidential sandbox app; its secret is held in memory only
-- * 5. grants media.object.upload|read, events.app.publish|read and services.release.manage,
-- * approved at once for the project owner inside the sandbox allowance
++ * 5. grants media.object.upload|read, events.app.publish|read, ai.app.run and
++ * services.release.manage, approved at once for the project owner inside the sandbox allowance
* 6. media examples/media-uploader: upload a small file, read it back, delete it
* 7. events examples/event-subscriber: publish app.<project_key>.developer_path.ran, pull it
-- * 8. export as the project's owner: Network mints read-only export tokens (5 minutes); Media
++ * 8. ai examples/ai-chat: the official openai client on OpenVibe.AI's OpenAI-compatible endpoint
++ * with an app token for openvibe.ai; one short answer, with its x_openvibe run id
++ * 9. export as the project's owner: Network mints read-only export tokens (5 minutes); Media
* lists the project's sandbox files with one and refuses an upload; Events returns the
* event from step 7 with the other (roadmap WS-N task 9, the project export)
-- * 9. release as the app, on OpenVibe.Services: a sandbox release of itself goes draft →
++ * 10. release as the app, on OpenVibe.Services: a sandbox release of itself goes draft →
* published → deprecated → revoked, checked in the public list at each step (a
* draft is never listed; a revoked release stays listed, marked revoked)
* (roadmap WS-N task 5, the app release flow in production)
-- * 10. credentials rotate with no overlap, revoke the first credential; the old secret is refused
++ * 11. credentials rotate with no overlap, revoke the first credential; the old secret is refused
* at /oauth/token and the new one works
-- * 11. cleanup archive the project (always attempted once it exists); the app's secret is
++ * 12. cleanup archive the project (always attempted once it exists); the app's secret is
* refused afterwards
*
* The same flow runs in CI against openvibe-sdk/testing's mock platform (scripts/developer-path-mock.js,
@@@ -35,7 +37,7 @@
*
* Credentials come from the environment only (or ./.env). It refuses to start without them, and
* refuses to run in CI against the production Network (CI set and OV_NETWORK_URL unset or
-- * openvibe.network). Optional: OV_NETWORK_URL, OV_SERVICES_URL. It never prints a password, secret or token: they
++ * openvibe.network). Optional: OV_NETWORK_URL, OV_SERVICES_URL, OV_AI_URL. It never prints a password, secret or token: they
* are masked in all output, and signed URLs are shown without their signature. Exit 0 when every
* step passed, 1 when one failed, 2 when it refused to start.
*/
@@@ -50,13 +52,15 @@ const ROOT = path.join(__dirname, '..');
const { createUploader, explain, loadConfig: mediaConfig } = require(path.join(ROOT, 'examples/media-uploader/upload'));
const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, 'examples/event-subscriber/publish'));
const { createSubscriber, createCursorStore, loadConfig: subscriberConfig } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
++const { createAiClient, ask, loadConfig: aiConfig } = require(path.join(ROOT, 'examples/ai-chat/chat'));
const PRODUCTION_NETWORK = 'https://openvibe.network';
const PRODUCTION_SERVICES = 'https://openvibe.services';
const PRODUCTION_MEDIA = 'https://openvibe.media';
const PRODUCTION_EVENTS = 'https://openvibe.events';
--const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'services.release.manage'];
--const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'export', 'release', 'credentials', 'cleanup'];
++const PRODUCTION_AI = 'https://ai.openvibe.services';
++const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'ai.app.run', 'services.release.manage'];
++const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'ai', 'export', 'release', 'credentials', 'cleanup'];
const STEP_TIMEOUT_MS = 120000;
const withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };
@@@ -98,11 +102,11 @@ async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
}
/**
-- * Runs the eleven steps. account: { token } | { username, password, register }. onSecret(value) is
++ * Runs the twelve steps. account: { token } | { username, password, register }. onSecret(value) is
* called with every password, secret and token as soon as it is known, so the caller can mask it.
* → { ok, steps: [{ name, ok, skipped?, error? }], projectId, appId }
*/
--async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODUCTION_SERVICES, media = PRODUCTION_MEDIA, events = PRODUCTION_EVENTS, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
++async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODUCTION_SERVICES, media = PRODUCTION_MEDIA, events = PRODUCTION_EVENTS, ai = PRODUCTION_AI, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
network = network.replace(/\/+$/, '');
services = services.replace(/\/+$/, '');
const say = (m) => log(` ${m}`);
@@@ -227,6 +231,17 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, services = PRODU
say(`pulled ${topics[0]} with events.app.read: ${n} event(s), found ${mine.id}`);
});
++ await step('ai', async () => {
++ // Production app runs go to free and local capacity (a small local model), so the prompt asks for little.
++ const config = aiConfig({ ...creds(), OV_AI_URL: `${ai.replace(/\/+$/, '')}/v1` });
++ const out = await ask(createAiClient(config, { fetch: fetchImpl }), config, 'Reply with one word: ready');
++ const text = String(out.text || '').trim();
++ if (!text) throw new Error('the answer was empty');
++ const run = out.openvibe || {};
++ if (!run.run_id) throw new Error('the answer carries no x_openvibe.run_id');
++ say(`chat.completions.create with the openai client and ai.app.run: ${JSON.stringify(text.slice(0, 40))} from ${out.model} (run ${run.run_id} on ${run.provider}, $${run.cost_usd})`);
++ });
++
await step('export', async () => {
const mint = async (audience) => {
const res = await fetchImpl(`${network}/api/v1/projects/${st.projectId}/export-tokens`, {
@@@ -360,7 +375,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
console.log(`OpenVibe developer path against ${network}\n`);
const started = Date.now();
const services = (env.OV_SERVICES_URL || PRODUCTION_SERVICES).replace(/\/+$/, '');
-- const result = await runDeveloperPath({ network, services, media: env.OV_MEDIA_URL || PRODUCTION_MEDIA, events: env.OV_EVENTS_URL || PRODUCTION_EVENTS, account, onSecret });
++ const result = await runDeveloperPath({ network, services, media: env.OV_MEDIA_URL || PRODUCTION_MEDIA, events: env.OV_EVENTS_URL || PRODUCTION_EVENTS, ai: env.OV_AI_URL || PRODUCTION_AI, account, onSecret });
// --result <file>: a JSON record for the scheduled production run (OpenVibe.Host openvibe-devpath.timer,
// shown on openvibe.network/status): step names and outcomes only — never an error text, URL, id or secret.
const out = argv.indexOf('--result');
@@@ -379,7 +394,7 @@ function writeResult(file, result, { network, started = Date.now(), now = Date.n
return record;
}
--module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_SERVICES };
++module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_SERVICES, PRODUCTION_AI };
if (require.main === module) {
main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });