openvibers/openvibe.examples Commits

Developer path check: the Wave 20 exit run as a committed, repeatable script

OP OpenVibers committed 23 Sept 2026 · 919d26be92 · 8 files, +495 −11

scripts/developer-path.js (npm run developer-path) turns the one-off production check into nine
steps with public endpoints, the SDK and the examples' own modules: account (register or sign in,
or OV_USER_TOKEN), discovery, a sandbox project, a confidential sandbox app, the four sandbox
grants, a Media upload/read/delete (media-uploader), an app event published and pulled
(event-subscriber), credential rotation with no overlap and revocation (old secret refused, new
one works), and archiving the project (always attempted; the secret is refused afterwards).

- Credentials from the environment (or ./.env) only; it refuses to start without them, and
  refuses in CI against the production Network. Passwords, secrets and tokens are masked in all
  output; signed URLs are shown without the signature. No values from the scratch run are kept.
- scripts/developer-path-mock.js (npm run developer-path:mock) runs the same flow against
  openvibe-sdk/testing's mock platform, adding only register/login routes that mint mock user
  tokens. A new CI job, developer-path, runs it on every push.
- test/developer-path.test.js: all nine steps pass on the mock, nothing is left behind, no secret
  or token is printed, a failed step skips the rest but still archives, and the CLI refusals.
  structure.test.js checks CI runs only the mock variant.
- README section "Developer path check"; STATUS updated. Not yet run against production.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 3456069..3f2dac6 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -15,3 +15,16 @@ jobs:
# Every example's smoke test runs against openvibe-sdk/testing's mock platform (and a small
# Chat WebSocket mock for chat-bot). No test reaches the network or a real service. `npm run e2e` (the real platform, needs credentials) is never run here.
- run: npm test
++ developer-path:
++ # The Wave 20 exit check (scripts/developer-path.js: account, project, sandbox app, grants,
++ # Media upload, app events, credential rotation and revocation, archive) step for step against
++ # openvibe-sdk/testing's mock platform. The real-platform run is started by a person only; the
++ # script refuses to run in CI against the production Network.
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ - run: npm ci
++ - run: npm run developer-path:mock
ddiff --git a/README.md b/README.md
iindex 5e478a3..48d8c4c 100644
---- a/README.md
++++ b/README.md
@@@ -6,7 +6,9 @@
**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on
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.
++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.4.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.4.0), and
openvibe-contracts v0.28.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
@@@ -119,6 +121,49 @@ Verified in production on 2026-09-23 with a brand-new account, using the public
- **Discovery.** `/.well-known/openvibe` and `/api/v1/registry/*` answer any origin (CORS), so a
browser page reads them directly.
++## 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.
++
++| Step | What it does |
++|---|---|
++| 1. account | registers `OV_E2E_USERNAME` (`--register`) or signs in at `/api/auth/*`, or uses `OV_USER_TOKEN` |
++| 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`; 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. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
++| 9. 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; every later 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:
++
++```bash
++OV_E2E_USERNAME=… OV_E2E_PASSWORD=… npm run developer-path # an existing account
++OV_E2E_USERNAME=… OV_E2E_PASSWORD=… npm run developer-path -- --register # create that account first
++OV_USER_TOKEN=… npm run developer-path # a Network user access token
++```
++
++It creates one project per run and archives it at the end (Network keeps archived projects).
++It refuses to start without credentials, and refuses in CI against the production Network (`CI`
++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
++nine 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.Codes.
++
## What still does not work
- **No consent screen.** Network's account chooser names the app but does not list the
@@@ -164,17 +209,22 @@ Repository checks (`test/`):
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
code reads, MIT license, `openvibe-sdk` pinned to the v0.4.0 tag, no `file:` links; and that
-- `npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
++ `npm run e2e` is outside CI and `npm test` and refuses to start without its variables; and that
++ CI runs the developer path against the mock platform only.
++- `developer-path.test.js` runs the developer path against the mock platform and checks the
++ result (see [Developer path check](#developer-path-check)).
## Layout
```
examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js
scripts/e2e.js npm run e2e: three examples against the real platform (on demand only)
++scripts/developer-path.js npm run developer-path: the Wave 20 exit check against the real platform (on demand only)
++scripts/developer-path-mock.js npm run developer-path:mock: the same flow against the mock platform (CI)
scripts/new-sandbox-app.js npm run new-app: project + sandbox app + grants via openvibe-sdk/projects
test/run.js runs every smoke test and the repository checks (npm test)
test/public-surface.test.js, test/structure.test.js
--.github/workflows/ci.yml Node 22.22.1: npm ci && npm test
++.github/workflows/ci.yml Node 22.22.1: npm test; the developer path against the mock platform
```
## Owns
@@@ -182,6 +232,7 @@ test/public-surface.test.js, test/structure.test.js
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
++- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
## Does not own
@@@ -197,15 +248,18 @@ test/public-surface.test.js, test/structure.test.js
## Acceptance
- CI runs every example: **yes**, against the SDK's mock platform (and a small Chat WebSocket
-- mock for chat-bot; no network). Against the real platform on demand: `npm run e2e` with a sandbox app's
-- credentials; there is no CI project with credentials.
++ mock for chat-bot; no network), and the developer path check against the same mock. Against
++ the real platform on demand: `npm run e2e` with a sandbox app's credentials and
++ `npm run developer-path` with an account; there is no integration environment with
++ credentials for CI.
- A new developer goes from account creation to a working Media, event and capability
integration using only public documentation, the SDK and scoped credentials: the platform path
was verified in production on 2026-09-23 (see
[How the platform works](#how-the-platform-works-for-a-sandbox-app)) by a curl script against the
-- public endpoints, not by the SDK or these examples, and that script is not committed to any
-- repository yet. `npm run e2e` walks three of the nine examples along the same path; it has not
-- been run against production. **Partly met.**
++ public endpoints, not by the SDK or these examples. That check is now committed as
++ `npm run developer-path` (SDK and examples, plus credential rotation and revocation), runs in CI
++ against the mock platform, and has not yet been run against production. `npm run e2e` walks
++ three of the nine examples along the same path. **Partly met.**
- No example needs a loopback-only internal key or first-party database access: **yes**, enforced
by `test/public-surface.test.js`.
ddiff --git a/STATUS.json b/STATUS.json
iindex f8f4ae2..c363314 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,9 +6,9 @@
"version": "0.2.0",
"sdk": "openvibe-sdk v0.4.0",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
-- "ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network",
++ "ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network; a second job runs the developer path check (scripts/developer-path.js) against the same mock platform",
"e2e": "npm run e2e runs media-uploader, event-subscriber (pull) and tool-job against the real platform with a sandbox app's OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID; on demand only, not in CI or npm test; not run against production as part of this release",
-- "platform": "sandbox developer path (projects, sandbox grants, app tokens for openvibe.media/events/tools, Media project tenant, Events app.<project_key>.* events, Tools jobs, CORS-open discovery) verified in production on 2026-09-23 by the platform's W20 exit check (a curl script against the public endpoints, not the SDK or these examples; not committed to a repository)",
++ "platform": "sandbox developer path (projects, sandbox grants, app tokens for openvibe.media/events/tools, Media project tenant, Events app.<project_key>.* events, Tools jobs, CORS-open discovery) verified in production on 2026-09-23 by the platform's W20 exit check (a curl script against the public endpoints); that check is now committed as npm run developer-path (SDK and examples, plus credential rotation, revocation and archive), which runs in CI against the mock platform and on demand against production (credentials from env only; refuses in CI against production); not yet run against production",
"not_working": [
"no consent screen; no refresh tokens or identity for apps",
"/oauth/token is not CORS-open: browser apps exchange codes server-side",
ddiff --git a/package.json b/package.json
iindex b4b1680..d1df02f 100644
---- a/package.json
++++ b/package.json
@@@ -11,7 +11,9 @@
"scripts": {
"test": "node test/run.js",
"e2e": "node --env-file-if-exists=.env scripts/e2e.js",
-- "new-app": "node --env-file-if-exists=.env scripts/new-sandbox-app.js"
++ "new-app": "node --env-file-if-exists=.env scripts/new-sandbox-app.js",
++ "developer-path": "node --env-file-if-exists=.env scripts/developer-path.js",
++ "developer-path:mock": "node scripts/developer-path-mock.js"
},
"engines": {
"node": ">=22"
ddiff --git a/scripts/developer-path-mock.js b/scripts/developer-path-mock.js
nnew file mode 100644
iindex 0000000..83e02ab
---- /dev/null
++++ b/scripts/developer-path-mock.js
@@@ -0,0 +1,60 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * npm run developer-path:mock: scripts/developer-path.js, step for step, against
++ * openvibe-sdk/testing's mock platform instead of the real one. CI runs it on every push. No
++ * network: anything that is not the mock fails.
++ *
++ * The mock platform answers Network's projects API, /oauth/token, discovery, Media and Events at
++ * their public origins with real RS256 tokens. It has no account API, so this adds the two routes
++ * the flow uses, /api/auth/register and /api/auth/login, as a thin layer: register creates a mock
++ * user and returns a Network user token for it; login returns one for an existing user (the mock
++ * does not check passwords). Everything after the account step is the platform mock's own.
++ */
++const { createMockPlatform } = require('openvibe-sdk/testing');
++const { runDeveloperPath, PRODUCTION_NETWORK } = 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' } });
++ 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();
++ if (url.origin === network && method === 'POST' && (url.pathname === '/api/auth/register' || url.pathname === '/api/auth/login')) {
++ const body = JSON.parse(init.body || '{}');
++ const username = String(body.username || '');
++ if (!username || !body.password) return reply(400, { error: 'Username and password required' });
++ if (url.pathname === '/api/auth/register') {
++ if (byName.has(username.toLowerCase())) return reply(409, { error: 'Username already taken' });
++ const user = platform.addUser({ username });
++ byName.set(username.toLowerCase(), user);
++ return reply(201, { token: platform.signUserToken(user), user: { username, subject_id: user.subject_id } });
++ }
++ const user = byName.get(username.toLowerCase());
++ return user ? reply(200, { token: platform.signUserToken(user) }) : reply(401, { error: 'Invalid credentials' });
++ }
++ return platform.fetch(input, init);
++ };
++}
++
++/** → { result, platform, output }. platformOptions go to createMockPlatform (tests use them to break a step). */
++async function runAgainstMock({ log, platformOptions = {} } = {}) {
++ const platform = createMockPlatform(platformOptions);
++ const lines = [];
++ const result = await runDeveloperPath({
++ network: PRODUCTION_NETWORK,
++ fetch: withAccounts(platform),
++ account: { username: 'developer_path', password: 'mock-password-not-checked', register: true },
++ log: (m) => { lines.push(m); if (log) log(m); },
++ });
++ return { result, platform, output: lines.join('\n') };
++}
++
++module.exports = { runAgainstMock, withAccounts };
++
++if (require.main === module) {
++ // CI has no network: anything that is not the mock platform fails loudly.
++ globalThis.fetch = () => { throw new Error('network access in the mock developer path'); };
++ console.log('OpenVibe developer path against openvibe-sdk/testing\'s mock platform\n');
++ runAgainstMock({ log: (m) => console.log(m) }).then(({ result }) => { process.exitCode = result.ok ? 0 : 1; }, (err) => { console.error(err); process.exitCode = 1; });
++}
ddiff --git a/scripts/developer-path.js b/scripts/developer-path.js
nnew file mode 100644
iindex 0000000..f7f5297
---- /dev/null
++++ b/scripts/developer-path.js
@@@ -0,0 +1,281 @@
++#!/usr/bin/env node
++'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
++ * 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 and events.app.publish|read, 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. credentials rotate with no overlap, revoke the first credential; the old secret is refused
++ * at /oauth/token and the new one works
++ * 9. 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,
++ * `npm run developer-path:mock`). Against the real platform it runs ONLY when a person starts it:
++ *
++ * OV_E2E_USERNAME=… OV_E2E_PASSWORD=… npm run developer-path # sign in
++ * OV_E2E_USERNAME=… OV_E2E_PASSWORD=… npm run developer-path -- --register # create that account first
++ * OV_USER_TOKEN=… npm run developer-path # an existing Network token
++ *
++ * 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. 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.
++ */
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
++const { createProjectsClient } = require('openvibe-sdk/projects');
++const { projectKey } = require('openvibe-sdk/events');
++
++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 PRODUCTION_NETWORK = 'https://openvibe.network';
++const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read'];
++const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', '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; } };
++const describeError = (err) => (isOpenVibeError(err) || (err && err.code && err.status !== undefined)
++ ? `${err.code}${err.status ? ` (${err.status})` : ''}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` request=${err.requestId}` : ''}`
++ : String((err && err.message) || err));
++
++function timeout(promise, what) {
++ let timer;
++ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} took longer than ${STEP_TIMEOUT_MS / 1000} s`)), STEP_TIMEOUT_MS); })])
++ .finally(() => clearTimeout(timer));
++}
++
++/** POST a JSON body to Network's public auth API → { status, body }. */
++async function postJson(fetchImpl, url, body) {
++ const res = await fetchImpl(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) });
++ return { status: res.status, body: await res.json().catch(() => ({})) };
++}
++
++/** POST /oauth/token client_credentials → { status, error } (the token itself is not kept). */
++async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
++ const res = await fetchImpl(`${network}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
++ body: new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret, audience }),
++ });
++ const body = await res.json().catch(() => ({}));
++ return { status: res.status, ok: res.ok && typeof body.access_token === 'string', error: body.error || null };
++}
++
++/**
++ * Runs the nine 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, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
++ network = network.replace(/\/+$/, '');
++ const say = (m) => log(` ${m}`);
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-developer-path-'));
++ const st = { token: null, projects: null, projectId: null, appId: null, secret: null, credentialId: null, newSecret: null };
++ const steps = [];
++ let failed = false;
++
++ async function step(name, fn, { always = false } = {}) {
++ log(`[${steps.length + 1}/${STEPS.length}] ${name}`);
++ if (failed && !always) { steps.push({ name, ok: false, skipped: true }); log(' skipped (an earlier step failed)\n'); return; }
++ try {
++ await timeout(fn(), name);
++ steps.push({ name, ok: true });
++ log(' ok\n');
++ } catch (err) {
++ failed = true;
++ steps.push({ name, ok: false, error: describeError(err) });
++ log(` FAILED: ${describeError(err)}\n`);
++ }
++ }
++ const creds = (secret = st.secret) => ({ OV_CLIENT_ID: st.appId, OV_CLIENT_SECRET: secret, OV_PROJECT_ID: st.projectId, OV_NETWORK_URL: network });
++
++ await step('account', async () => {
++ if (account.password) onSecret(account.password);
++ if (account.token) {
++ onSecret(account.token);
++ st.token = account.token;
++ say('using OV_USER_TOKEN');
++ return;
++ }
++ if (account.register) {
++ const r = await postJson(fetchImpl, `${network}/api/auth/register`, { username: account.username, password: account.password });
++ if (r.status === 201 || r.status === 200) {
++ st.token = r.body.token;
++ say(`registered a new account ${account.username}`);
++ } else if (r.status === 409) {
++ say(`${account.username} already exists; signing in instead (this run does not prove account creation)`);
++ } else {
++ throw new Error(`registration refused (${r.status}): ${r.body.error || 'no detail'}`);
++ }
++ }
++ if (!st.token) {
++ const r = await postJson(fetchImpl, `${network}/api/auth/login`, { username: account.username, password: account.password });
++ if (!r.body.token) throw new Error(`sign-in refused (${r.status}): ${r.body.error || 'no detail'}`);
++ st.token = r.body.token;
++ say(`signed in as ${account.username}`);
++ }
++ onSecret(st.token);
++ st.projects = createProjectsClient(createClient({ network, fetch: fetchImpl, token: st.token }));
++ });
++
++ await step('discovery', async () => {
++ const res = await fetchImpl(`${network}/.well-known/openvibe`, { headers: { Accept: 'application/json', Origin: 'https://example.dev' } });
++ if (!res.ok) throw new Error(`/.well-known/openvibe answered ${res.status}`);
++ const d = await res.json();
++ if (!d.name || !d.token_endpoint) throw new Error('the discovery document has no name or token_endpoint');
++ say(`${d.name}: token endpoint ${d.token_endpoint}${d.registry ? `, registry ${d.registry}` : ''}`);
++ });
++
++ await step('project', async () => {
++ if (!st.projects) st.projects = createProjectsClient(createClient({ network, fetch: fetchImpl, token: st.token }));
++ const p = await st.projects.create({ name: `Developer path check ${now().toISOString().slice(0, 16).replace('T', ' ')}` });
++ const project = p.project || p;
++ st.projectId = project.id;
++ say(`project ${project.id} (${(project.environments || ['sandbox']).join(', ')}), role ${project.role || 'owner'}`);
++ });
++
++ await step('app', async () => {
++ const created = await st.projects.apps.create(st.projectId, { name: 'developer-path', environment: 'sandbox', type: 'confidential', redirectUris: ['http://localhost:3009/callback'] });
++ const app = created.app || created;
++ const credential = created.credential || app.credential || {};
++ st.appId = app.id;
++ st.secret = credential.client_secret || created.client_secret;
++ if (!st.secret) throw new Error('Network returned no client secret for a confidential app');
++ onSecret(st.secret);
++ st.credentialId = credential.id;
++ say(`app ${st.appId} (sandbox, confidential), credential ${credential.id || '?'} ending …${credential.hint || '?'}; secret received once, kept in memory only`);
++ });
++
++ await step('grants', async () => {
++ const statuses = {};
++ for (const capability of GRANTS) {
++ const g = await st.projects.grants.request(st.projectId, st.appId, capability);
++ statuses[capability] = (g.grant || g).status;
++ say(`${capability}: ${statuses[capability]}`);
++ }
++ const notApproved = GRANTS.filter((c) => statuses[c] !== 'approved');
++ if (notApproved.length) throw new Error(`not approved: ${notApproved.join(', ')}`);
++ });
++
++ await step('media', async () => {
++ const file = path.join(dir, 'developer-path.txt');
++ fs.writeFileSync(file, `OpenVibe developer path check ${now().toISOString()}\n`);
++ const uploader = createUploader(mediaConfig(creds()), { fetch: fetchImpl });
++ let out;
++ try { out = await uploader.upload(file); } catch (err) { throw Object.assign(err, { detail: explain(err) }); }
++ say(`uploaded ${out.key} (${out.size} bytes, ${out.mime}) into project ${out.project_id}; sandbox=${out.sandbox} url=${withoutQuery(out.url)}`);
++ const meta = await uploader.get(out.key);
++ if (!meta || meta.sha256 !== out.sha256) throw new Error('media.object.read did not return the file just uploaded');
++ say(`read back with media.object.read: sha256 ${meta.sha256.slice(0, 16)}…`);
++ if (!(await uploader.remove(out.key))) throw new Error('the delete removed nothing');
++ say('deleted');
++ });
++
++ await step('events', async () => {
++ const pub = await publishAppEvent(publishConfig(creds()), { name: 'developer_path.ran', subject: { type: 'check', id: 'developer-path' }, payload: { at: now().toISOString() } }, { fetch: fetchImpl });
++ say(`published ${pub.event_type} ${pub.event_id} -> seq ${pub.seq}`);
++ const cursorPath = path.join(dir, 'cursor.json');
++ createCursorStore(cursorPath).set('pull', Math.max(0, pub.seq - 1));
++ const seen = [];
++ const s = createSubscriber(subscriberConfig({ ...creds(), OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {
++ fetch: fetchImpl, log: { log: () => {}, error: (m) => say(m) }, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id }); },
++ });
++ const topics = await s.resolveTopics();
++ if (topics[0] !== `app.${projectKey(st.projectId)}.*`) throw new Error(`unexpected topic ${topics[0]}`);
++ const n = await s.pullOnce();
++ const mine = seen.find((e) => e.id === pub.event_id);
++ if (!mine) throw new Error(`the published event ${pub.event_id} was not in the pull of ${topics[0]}`);
++ say(`pulled ${topics[0]} with events.app.read: ${n} event(s), found ${mine.id} at seq ${mine.seq}`);
++ });
++
++ await step('credentials', async () => {
++ const rotated = await st.projects.credentials.rotate(st.projectId, st.appId, { overlapSeconds: 0 });
++ st.newSecret = rotated.credential.client_secret;
++ onSecret(st.newSecret);
++ say(`rotated: new credential ${rotated.credential.id} ending …${rotated.credential.hint}, no overlap`);
++ if (st.credentialId) {
++ const revoked = await st.projects.credentials.revoke(st.projectId, st.appId, st.credentialId);
++ say(`revoked ${st.credentialId}: ${(revoked.credential || revoked).state || 'revoked'}`);
++ }
++ const old = await tryToken(fetchImpl, network, st.appId, st.secret, 'openvibe.media');
++ if (old.ok) throw new Error('the revoked secret still gets a token');
++ say(`old secret refused at /oauth/token: ${old.status} ${old.error}`);
++ const fresh = await tryToken(fetchImpl, network, st.appId, st.newSecret, 'openvibe.media');
++ if (!fresh.ok) throw new Error(`the new secret gets no token: ${fresh.status} ${fresh.error}`);
++ say('new secret gets a token');
++ });
++
++ await step('cleanup', async () => {
++ if (!st.projectId) { say('no project was created'); return; }
++ await st.projects.archive(st.projectId);
++ say(`archived ${st.projectId}: every app in it is revoked`);
++ if (st.appId && (st.newSecret || st.secret)) {
++ const after = await tryToken(fetchImpl, network, st.appId, st.newSecret || st.secret, 'openvibe.media');
++ if (after.ok) throw new Error('the app still gets a token after the project was archived');
++ say(`the app's secret is refused: ${after.status} ${after.error}`);
++ }
++ }, { always: true });
++
++ fs.rmSync(dir, { recursive: true, force: true });
++ const ok = steps.every((s) => s.ok);
++ log(`${steps.filter((s) => s.ok).length}/${steps.length} steps ok`);
++ return { ok, steps, projectId: st.projectId, appId: st.appId };
++}
++
++/** Mask every known secret, and anything token-shaped, in everything this process writes. */
++function maskOutput() {
++ const secrets = new Set();
++ const mask = (s) => {
++ let out = String(s);
++ for (const v of secrets) out = out.split(v).join('[secret]');
++ return out.replace(/eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, '[token]');
++ };
++ for (const stream of [process.stdout, process.stderr]) {
++ const write = stream.write.bind(stream);
++ stream.write = (chunk, ...rest) => write(typeof chunk === 'string' || Buffer.isBuffer(chunk) ? mask(chunk.toString()) : chunk, ...rest);
++ }
++ return (v) => { if (v && String(v).length >= 4) secrets.add(String(v)); };
++}
++
++function refuse(message) {
++ console.error(message);
++ console.error('\nusage: OV_E2E_USERNAME=… OV_E2E_PASSWORD=… npm run developer-path [-- --register]');
++ console.error(' or: OV_USER_TOKEN=… npm run developer-path');
++ return 2;
++}
++
++async function main(argv = process.argv.slice(2), env = process.env) {
++ const network = (env.OV_NETWORK_URL || PRODUCTION_NETWORK).replace(/\/+$/, '');
++ if (env.CI && new URL(network).host === new URL(PRODUCTION_NETWORK).host) {
++ return refuse('developer-path: refusing to run against the production Network in CI (CI is set). CI runs npm run developer-path:mock. Nothing was sent.');
++ }
++ const register = argv.includes('--register');
++ let account;
++ if (env.OV_USER_TOKEN && !register) account = { token: env.OV_USER_TOKEN };
++ else if (env.OV_E2E_USERNAME && env.OV_E2E_PASSWORD) account = { username: env.OV_E2E_USERNAME, password: env.OV_E2E_PASSWORD, register };
++ else return refuse(`developer-path: missing ${register ? 'OV_E2E_USERNAME and OV_E2E_PASSWORD' : 'OV_E2E_USERNAME and OV_E2E_PASSWORD, or OV_USER_TOKEN'}. Nothing was sent.`);
++ const onSecret = maskOutput();
++ console.log(`OpenVibe developer path against ${network}\n`);
++ const result = await runDeveloperPath({ network, account, onSecret });
++ return result.ok ? 0 : 1;
++}
++
++module.exports = { runDeveloperPath, GRANTS, STEPS, PRODUCTION_NETWORK };
++
++if (require.main === module) {
++ main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });
++}
ddiff --git a/test/developer-path.test.js b/test/developer-path.test.js
nnew file mode 100644
iindex 0000000..9a0af39
---- /dev/null
++++ b/test/developer-path.test.js
@@@ -0,0 +1,65 @@
++'use strict';
++/**
++ * The developer path (scripts/developer-path.js) against openvibe-sdk/testing's mock platform:
++ * every step passes, it leaves nothing behind (file deleted, credential revoked, project archived),
++ * it never writes a secret or a token, a failed step skips the rest but still archives the project,
++ * and the real-platform CLI refuses to start without credentials or in CI against production.
++ */
++const assert = require('node:assert/strict');
++const { spawnSync } = require('node:child_process');
++const path = require('node:path');
++const { runAgainstMock } = require('../scripts/developer-path-mock');
++const { STEPS } = require('../scripts/developer-path');
++
++globalThis.fetch = () => { throw new Error('network access in a test'); };
++const CLI = path.join(__dirname, '..', 'scripts', 'developer-path.js');
++
++(async () => {
++ // 1. The whole path passes on the mock platform.
++ const { result, platform, output } = await runAgainstMock();
++ assert.equal(result.ok, true, output);
++ assert.deepEqual(result.steps.map((s) => [s.name, s.ok]), STEPS.map((n) => [n, true]));
++ assert.match(result.projectId, /^prj_/);
++ assert.match(result.appId, /^app_/);
++
++ // 2. Nothing is left behind.
++ assert.equal([...platform.state.files.keys()].filter((k) => k.startsWith(result.projectId)).length, 0, 'the uploaded file was deleted');
++ const project = platform.state.projects.get(result.projectId);
++ assert.ok(project.archived_at, 'the project is archived');
++ const app = platform.state.apps.get(result.appId);
++ assert.ok(app.revoked_at, 'archiving revoked the app');
++ assert.equal(app.credentials.length, 2, 'the original credential and the rotated one');
++ assert.ok(app.credentials[0].revoked_at, 'the first credential was revoked');
++
++ // 3. No secret or token in the output. The app's secrets are only in the mock's state.
++ for (const s of app.credentials.map((c) => c.secret)) assert.ok(!output.includes(s), 'a client secret was printed');
++ assert.ok(!/eyJ[\w-]{8,}\.[\w-]{8,}\./.test(output), 'a token was printed');
++ assert.ok(!output.includes('mock-password-not-checked'), 'the password was printed');
++ assert.match(output, /url=https:\/\/openvibe\.media\/f\/\S+ \(signed\)/, 'signed URLs without the signature');
++
++ // 4. A failing step (no grants approved: an empty allowance) skips the rest but still cleans up.
++ const broken = await runAgainstMock({ platformOptions: { defaultAllowance: [] } });
++ assert.equal(broken.result.ok, false);
++ const byName = Object.fromEntries(broken.result.steps.map((s) => [s.name, s]));
++ assert.equal(byName.grants.ok, false);
++ assert.match(byName.grants.error, /not approved: media\.object\.upload/);
++ for (const n of ['media', 'events', 'credentials']) assert.equal(byName[n].skipped, true, `${n} skipped`);
++ assert.equal(byName.cleanup.ok, true, 'cleanup still ran');
++ assert.match(broken.output, /archived prj_/);
++
++ // 5. The real-platform CLI refuses (exit 2, nothing sent) without credentials, and in CI
++ // against the production Network even with them.
++ const run = (env, args = []) => spawnSync(process.execPath, [CLI, ...args], { env: { PATH: process.env.PATH, ...env }, encoding: 'utf8', timeout: 20000 });
++ const none = run({});
++ assert.equal(none.status, 2, none.stderr);
++ assert.match(none.stderr, /missing OV_E2E_USERNAME and OV_E2E_PASSWORD, or OV_USER_TOKEN\. Nothing was sent\./);
++ const reg = run({ OV_USER_TOKEN: 'x' }, ['--register']);
++ assert.equal(reg.status, 2, 'registering needs a username and password');
++ const ci = run({ CI: 'true', OV_E2E_USERNAME: 'someone', OV_E2E_PASSWORD: 'secret-value' });
++ assert.equal(ci.status, 2);
++ assert.match(ci.stderr, /refusing to run against the production Network in CI/);
++ const ciExplicit = run({ CI: 'true', OV_NETWORK_URL: 'https://openvibe.network/', OV_USER_TOKEN: 'x' });
++ assert.equal(ciExplicit.status, 2, 'also with OV_NETWORK_URL set to production');
++
++ console.log('developer-path: ok');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 7a0e8bd..37f70b4 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -70,6 +70,15 @@ assert.ok(!fs.existsSync(path.join(ROOT, 'test', 'e2e.test.js')), 'the root runn
const refused = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'e2e.js')], { env: { PATH: process.env.PATH }, encoding: 'utf8', timeout: 20000 });
assert.equal(refused.status, 2, refused.stderr);
assert.match(refused.stderr, /missing OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID\. Nothing was sent\./);
++
++// npm run developer-path: the Wave 20 exit check. CI runs it against the mock platform only
++// (npm run developer-path:mock); the real-platform script is never a CI step and refuses in CI
++// against production (test/developer-path.test.js).
++assert.match(rootPkg.scripts['developer-path'], /scripts\/developer-path\.js/);
++assert.equal(rootPkg.scripts['developer-path:mock'], 'node scripts/developer-path-mock.js');
++const ciRuns = ci.split('\n').filter((l) => /^\s*-?\s*run:/.test(l)).map((l) => l.trim());
++assert.ok(ciRuns.includes('- run: npm run developer-path:mock'), 'CI runs the developer path against the mock platform');
++assert.ok(!ciRuns.some((l) => /developer-path(?!:mock)|scripts\/developer-path\.js/.test(l)), 'CI never runs the real-platform developer path');
assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');
assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
console.log('structure: ok');