openvibers/openvibe.examples Commits
Developer path check: the Wave 20 exit run as a committed, repeatable script
Changes
@@@ -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@@@ -6,7 +6,9 @@**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs onNode 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs threeof 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), andopenvibe-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 abrowser 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 itproves" and "Run it against the real platform", a `.env.example` that names every variable thecode 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.jsscripts/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/projectstest/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, eventsubscriber, 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 capabilityintegration using only public documentation, the SDK and scoped credentials: the platform pathwas 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**, enforcedby `test/public-surface.test.js`.@@@ -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",@@@ -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"nnew file mode 100644@@@ -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; });++}nnew file mode 100644@@@ -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; });++}nnew file mode 100644@@@ -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); });@@@ -70,6 +70,15 @@ assert.ok(!fs.existsSync(path.join(ROOT, 'test', 'e2e.test.js')), 'the root runnconst 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');
@@ -15,3 +15,16 @@ jobs:
@@ -15,3 +15,16 @@ jobs:
# Every example's smoke test runs against openvibe-sdk/testing's mock platform (and a small
# 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.
# 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
- 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
@@ -6,7 +6,9 @@
@@ -6,7 +6,9 @@
**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on
**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
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
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
**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.
openvibe-contracts v0.28.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
**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
@@ -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
- **Discovery.** `/.well-known/openvibe` and `/api/v1/registry/*` answer any origin (CORS), so a
browser page reads them directly.
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
## What still does not work
- **No consent screen.** Network's account chooser names the app but does not list the
- **No consent screen.** Network's account chooser names the app but does not list the
@@ -164,17 +209,22 @@ Repository checks (`test/`):
@@ -164,17 +209,22 @@ Repository checks (`test/`):
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
- `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
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
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
## Layout
```
```
examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js
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/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
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/run.js runs every smoke test and the repository checks (npm test)
test/public-surface.test.js, test/structure.test.js
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
## Owns
@@ -182,6 +232,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -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
- 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
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 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
## Does not own
@@ -197,15 +248,18 @@ test/public-surface.test.js, test/structure.test.js
@@ -197,15 +248,18 @@ test/public-surface.test.js, test/structure.test.js
## Acceptance
## Acceptance
- CI runs every example: **yes**, against the SDK's mock platform (and a small Chat WebSocket
- 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
- 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
integration using only public documentation, the SDK and scoped credentials: the platform path
was verified in production on 2026-09-23 (see
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
[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
- No example needs a loopback-only internal key or first-party database access: **yes**, enforced
by `test/public-surface.test.js`.
by `test/public-surface.test.js`.
@@ -6,9 +6,9 @@
@@ -6,9 +6,9 @@
"version": "0.2.0",
"version": "0.2.0",
"sdk": "openvibe-sdk v0.4.0",
"sdk": "openvibe-sdk v0.4.0",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
"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",
"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": [
"not_working": [
"no consent screen; no refresh tokens or identity for apps",
"no consent screen; no refresh tokens or identity for apps",
"/oauth/token is not CORS-open: browser apps exchange codes server-side",
"/oauth/token is not CORS-open: browser apps exchange codes server-side",
@@ -11,7 +11,9 @@
@@ -11,7 +11,9 @@
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
"e2e": "node --env-file-if-exists=.env scripts/e2e.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": {
"engines": {
"node": ">=22"
"node": ">=22"
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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; });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,281 @@
@@ -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; });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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); });
@@ -70,6 +70,15 @@ assert.ok(!fs.existsSync(path.join(ROOT, 'test', 'e2e.test.js')), 'the root runn
@@ -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 });
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.equal(refused.status, 2, refused.stderr);
assert.match(refused.stderr, /missing OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID\. Nothing was sent\./);
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.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');
assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
console.log('structure: ok');
console.log('structure: ok');