openvibers/openvibe.examples Commits
Developer path: a release step (roadmap WS-N task 5, the app release flow in production): as the app, with codes.release.manage and a token for openvibe.codes, a sandbox release of itself goes draft → published → deprecated → revoked on OpenVibe.Codes, checked in the public list each time. The mock stands in for Codes' release API (claims decide), so CI covers it; OV_CODES_URL overrides the origin
Changes
@@@ -154,17 +154,18 @@ scoped credentials only, then rotates and revokes those credentials and cleans u| 2. discovery | reads `/.well-known/openvibe` as any origin may || 3. project | creates a sandbox project with `openvibe-sdk/projects` || 4. app | creates a confidential sandbox app; its secret stays in memory |--| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`; all must be approved at once |++| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `codes.release.manage`; all must be approved at once || 6. media | [media-uploader](examples/media-uploader): upload a small file, read it back, delete it || 7. events | [event-subscriber](examples/event-subscriber): publish `app.<project_key>.developer_path.ran`, pull it with a cursor |--| 8. 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 |++| 8. release | as the app, on OpenVibe.Codes (`OV_CODES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |++| 9. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |++| 10. 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.++that mint mock user tokens, and stands in for Codes' release API; every other step is the platform mock's own.**Against production** it runs only when a person starts it. Credentials come from theenvironment (or `./.env`) only:@@@ -180,7 +181,7 @@ It refuses to start without credentials, and refuses in CI against the productioset and `OV_NETWORK_URL` unset or `https://openvibe.network`), so it can later run in CI againstan 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.++ten 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.@@@ -9,14 +9,44 @@* 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.++ * does not check passwords). It also stands in for OpenVibe.Codes' release API (the release step):++ * an app token for openvibe.codes carrying codes.release.manage manages the app's own releases,++ * draft → published → deprecated → revoked, and the public list shows published and deprecated ones.++ * Everything else is the platform mock's own.*/const { createMockPlatform } = require('openvibe-sdk/testing');--const { runDeveloperPath, PRODUCTION_NETWORK } = require('./developer-path');++const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_CODES } = 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' } });++ // OpenVibe.Codes' release API, in memory (the token's signature is the platform's; here its claims decide).++ const releases = new Map();++ let n = 0;++ function codes(url, method, init) {++ const m = url.pathname.match(/^\/api\/v1\/(?:apps\/(app_[0-9A-Z]+)\/releases|releases\/([a-z0-9_]+)\/(publish|deprecate|revoke))$/i);++ if (!m) return reply(404, { code: 'route.not_found' });++ const list = (appId) => [...releases.values()].filter((r) => r.app_id === appId && ['published', 'deprecated'].includes(r.status));++ if (m[1] && method === 'GET') return reply(200, { app_id: m[1], releases: list(m[1]) });++ let claims = null;++ try { claims = JSON.parse(Buffer.from(String((init.headers || {}).Authorization || '').replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8')); } catch { claims = null; }++ if (!claims) return reply(401, { code: 'auth.required' });++ if (!(claims.aud || []).includes('openvibe.codes') || !(claims.cap || []).includes('codes.release.manage')) return reply(403, { code: 'capability.denied' });++ const body = JSON.parse(init.body || '{}');++ if (m[1]) {++ if (claims.sub !== `app:${m[1]}` || !body.manifest || body.manifest.id !== m[1]) return reply(403, { code: 'release.forbidden' });++ const rel = { id: `rel_mock${++n}`, app_id: m[1], version: body.manifest.version, status: body.publish ? 'published' : 'draft' };++ releases.set(rel.id, rel);++ return reply(201, { release: rel, warnings: [] });++ }++ const rel = releases.get(m[2]);++ if (!rel || claims.sub !== `app:${rel.app_id}`) return reply(404, { code: 'release.not_found' });++ const next = { publish: ['draft', 'published'], deprecate: ['published', 'deprecated'], revoke: [null, 'revoked'] }[m[3]];++ if (next[0] && rel.status !== next[0]) return reply(409, { code: 'release.invalid_state' });++ rel.status = next[1];++ return reply(200, { release: rel });++ }++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();@@@ -33,6 +63,7 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {const user = byName.get(username.toLowerCase());return user ? reply(200, { token: platform.signUserToken(user) }) : reply(401, { error: 'Invalid credentials' });}++ if (url.origin === PRODUCTION_CODES) return codes(url, method, init);return platform.fetch(input, init);};}@@@ -9,13 +9,16 @@* 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++ * 5. grants media.object.upload|read, events.app.publish|read and codes.release.manage,++ * approved at once for the project owner inside the sandbox allowance* 6. media examples/media-uploader: upload a small file, read it back, delete it* 7. events examples/event-subscriber: publish app.<project_key>.developer_path.ran, pull it-- * 8. credentials rotate with no overlap, revoke the first credential; the old secret is refused++ * 8. release as the app, on OpenVibe.Codes: a sandbox release of itself goes draft →++ * published → deprecated → revoked, checked in the public list at each step++ * (roadmap WS-N task 5, the app release flow in production)++ * 9. 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++ * 10. 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,@@@ -28,7 +31,7 @@** Credentials come from the environment only (or ./.env). It refuses to start without them, and* refuses to run in CI against the production Network (CI set and OV_NETWORK_URL unset or-- * openvibe.network). Optional: OV_NETWORK_URL. It never prints a password, secret or token: they++ * openvibe.network). Optional: OV_NETWORK_URL, OV_CODES_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.*/@@@ -45,8 +48,9 @@ const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, '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 PRODUCTION_CODES = 'https://openvibe.codes';++const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'codes.release.manage'];++const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'release', 'credentials', 'cleanup'];const STEP_TIMEOUT_MS = 120000;const withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };@@@ -66,6 +70,17 @@ async function postJson(fetchImpl, url, body) {return { status: res.status, body: await res.json().catch(() => ({})) };}++/** POST /oauth/token client_credentials → the app's access token for `audience`, or throws. */++async function appToken(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(() => ({}));++ if (!res.ok || typeof body.access_token !== 'string') throw new Error(`no token for ${audience}: ${res.status} ${body.error || ''}`);++ return body.access_token;++}++/** 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`, {@@@ -77,12 +92,13 @@ async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {}/**-- * Runs the nine steps. account: { token } | { username, password, register }. onSecret(value) is++ * Runs the ten 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() }) {++async function runDeveloperPath({ network = PRODUCTION_NETWORK, codes = PRODUCTION_CODES, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {network = network.replace(/\/+$/, '');++ codes = codes.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 };@@@ -203,6 +219,39 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, fetch: fetchImplsay(`pulled ${topics[0]} with events.app.read: ${n} event(s), found ${mine.id} at seq ${mine.seq}`);});++ await step('release', async () => {++ const token = await appToken(fetchImpl, network, st.appId, st.secret, 'openvibe.codes');++ onSecret(token);++ const call = async (method, p, body, auth = true) => {++ const res = await fetchImpl(`${codes}/api/v1${p}`, {++ method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(auth ? { Authorization: `Bearer ${token}` } : {}) },++ body: body ? JSON.stringify(body) : undefined,++ });++ const out = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error(`${method} ${p}: ${res.status} ${out.code || ''} ${out.detail || ''}`.trim());++ return out;++ };++ const listed = async (id) => ((await call('GET', `/apps/${st.appId}/releases`, null, false)).releases || []).find((r) => r.id === id);++ const version = `1.0.${Math.floor(now().getTime() / 1000)}`;++ const manifest = {++ id: st.appId, name: 'developer-path', version, publisher: { type: 'app', id: st.appId }, project_id: st.projectId, environment: 'sandbox', capabilities: [],++ compatibility: { contracts: `^${require('openvibe-contracts/package.json').version}`, sdk: `^${require('openvibe-sdk/package.json').version}` },++ };++ const draft = (await call('POST', `/apps/${st.appId}/releases`, { kind: 'app', manifest, notes: 'OpenVibe developer path check', publish: false })).release;++ if (!draft || draft.status !== 'draft') throw new Error(`the new release is ${draft && draft.status}, not draft`);++ if (await listed(draft.id)) throw new Error('a draft is listed publicly');++ say(`draft ${draft.id} (${version})`);++ const steps = [['publish', {}, 'published', true], ['deprecate', { reason: 'developer path check' }, 'deprecated', true], ['revoke', { reason: 'developer path check done' }, 'revoked', false]];++ for (const [action, body, want, shown] of steps) {++ const r = await call('POST', `/releases/${draft.id}/${action}`, body);++ const status = (r.release || r).status;++ if (status !== want) throw new Error(`${action} left it ${status}, not ${want}`);++ const pub = await listed(draft.id);++ if (shown ? !(pub && pub.status === want) : pub) throw new Error(`after ${action} the public list ${pub ? `shows it ${pub.status}` : 'does not show it'}`);++ say(`${action}: ${want}${shown ? ', in the public list' : ', gone from the public list'}`);++ }++ });++await step('credentials', async () => {const rotated = await st.projects.credentials.rotate(st.projectId, st.appId, { overlapSeconds: 0 });st.newSecret = rotated.credential.client_secret;@@@ -272,7 +321,8 @@ async function main(argv = process.argv.slice(2), env = process.env) {const onSecret = maskOutput();console.log(`OpenVibe developer path against ${network}\n`);const started = Date.now();-- const result = await runDeveloperPath({ network, account, onSecret });++ const codes = (env.OV_CODES_URL || PRODUCTION_CODES).replace(/\/+$/, '');++ const result = await runDeveloperPath({ network, codes, account, onSecret });// --result <file>: a JSON record for the scheduled production run (OpenVibe.Host openvibe-devpath.timer,// shown on openvibe.network/status): step names and outcomes only — never an error text, URL, id or secret.const out = argv.indexOf('--result');@@@ -291,7 +341,7 @@ function writeResult(file, result, { network, started = Date.now(), now = Date.nreturn record;}--module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK };++module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_CODES };if (require.main === module) {main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });
@@ -154,17 +154,18 @@ scoped credentials only, then rotates and revokes those credentials and cleans u
@@ -154,17 +154,18 @@ scoped credentials only, then rotates and revokes those credentials and cleans u
| 2. discovery | reads `/.well-known/openvibe` as any origin may |
| 2. discovery | reads `/.well-known/openvibe` as any origin may |
| 3. project | creates a sandbox project with `openvibe-sdk/projects` |
| 3. project | creates a sandbox project with `openvibe-sdk/projects` |
| 4. app | creates a confidential sandbox app; its secret stays in memory |
| 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 |
+| 5. grants | requests `media.object.upload`, `media.object.read`, `events.app.publish`, `events.app.read`, `codes.release.manage`; all must be approved at once |
| 6. media | [media-uploader](examples/media-uploader): upload a small file, read it back, delete it |
| 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 |
| 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 |
+| 8. release | as the app, on OpenVibe.Codes (`OV_CODES_URL`): a sandbox release of itself goes draft → published → deprecated → revoked, checked in the public list each time |
+| 9. credentials | rotates with no overlap and revokes the first credential; the old secret must be refused at `/oauth/token`, the new one must work |
+| 10. 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
**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`
`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).
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
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.
+that mint mock user tokens, and stands in for Codes' release API; every other step is the platform mock's own.
**Against production** it runs only when a person starts it. Credentials come from the
**Against production** it runs only when a person starts it. Credentials come from the
environment (or `./.env`) only:
environment (or `./.env`) only:
@@ -180,7 +181,7 @@ It refuses to start without credentials, and refuses in CI against the productio
@@ -180,7 +181,7 @@ It refuses to start without credentials, and refuses in CI against the productio
set and `OV_NETWORK_URL` unset or `https://openvibe.network`), so it can later run in CI against
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,
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
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.
+ten 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)
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.
or publishing an app release in OpenVibe.Codes.
@@ -9,14 +9,44 @@
@@ -9,14 +9,44 @@
* their public origins with real RS256 tokens. It has no account API, so this adds the two routes
* 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
* 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
* 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.
+ * does not check passwords). It also stands in for OpenVibe.Codes' release API (the release step):
+ * an app token for openvibe.codes carrying codes.release.manage manages the app's own releases,
+ * draft → published → deprecated → revoked, and the public list shows published and deprecated ones.
+ * Everything else is the platform mock's own.
*/
*/
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createMockPlatform } = require('openvibe-sdk/testing');
-const { runDeveloperPath, PRODUCTION_NETWORK } = require('./developer-path');
+const { runDeveloperPath, PRODUCTION_NETWORK, PRODUCTION_CODES } = require('./developer-path');
function withAccounts(platform, network = PRODUCTION_NETWORK) {
function withAccounts(platform, network = PRODUCTION_NETWORK) {
const byName = new Map();
const byName = new Map();
const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ // OpenVibe.Codes' release API, in memory (the token's signature is the platform's; here its claims decide).
+ const releases = new Map();
+ let n = 0;
+ function codes(url, method, init) {
+ const m = url.pathname.match(/^\/api\/v1\/(?:apps\/(app_[0-9A-Z]+)\/releases|releases\/([a-z0-9_]+)\/(publish|deprecate|revoke))$/i);
+ if (!m) return reply(404, { code: 'route.not_found' });
+ const list = (appId) => [...releases.values()].filter((r) => r.app_id === appId && ['published', 'deprecated'].includes(r.status));
+ if (m[1] && method === 'GET') return reply(200, { app_id: m[1], releases: list(m[1]) });
+ let claims = null;
+ try { claims = JSON.parse(Buffer.from(String((init.headers || {}).Authorization || '').replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8')); } catch { claims = null; }
+ if (!claims) return reply(401, { code: 'auth.required' });
+ if (!(claims.aud || []).includes('openvibe.codes') || !(claims.cap || []).includes('codes.release.manage')) return reply(403, { code: 'capability.denied' });
+ const body = JSON.parse(init.body || '{}');
+ if (m[1]) {
+ if (claims.sub !== `app:${m[1]}` || !body.manifest || body.manifest.id !== m[1]) return reply(403, { code: 'release.forbidden' });
+ const rel = { id: `rel_mock${++n}`, app_id: m[1], version: body.manifest.version, status: body.publish ? 'published' : 'draft' };
+ releases.set(rel.id, rel);
+ return reply(201, { release: rel, warnings: [] });
+ }
+ const rel = releases.get(m[2]);
+ if (!rel || claims.sub !== `app:${rel.app_id}`) return reply(404, { code: 'release.not_found' });
+ const next = { publish: ['draft', 'published'], deprecate: ['published', 'deprecated'], revoke: [null, 'revoked'] }[m[3]];
+ if (next[0] && rel.status !== next[0]) return reply(409, { code: 'release.invalid_state' });
+ rel.status = next[1];
+ return reply(200, { release: rel });
+ }
+
return async (input, init = {}) => {
return async (input, init = {}) => {
const url = new URL(typeof input === 'string' ? input : input.url);
const url = new URL(typeof input === 'string' ? input : input.url);
const method = (init.method || (typeof input === 'string' ? 'GET' : input.method) || 'GET').toUpperCase();
const method = (init.method || (typeof input === 'string' ? 'GET' : input.method) || 'GET').toUpperCase();
@@ -33,6 +63,7 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
@@ -33,6 +63,7 @@ function withAccounts(platform, network = PRODUCTION_NETWORK) {
const user = byName.get(username.toLowerCase());
const user = byName.get(username.toLowerCase());
return user ? reply(200, { token: platform.signUserToken(user) }) : reply(401, { error: 'Invalid credentials' });
return user ? reply(200, { token: platform.signUserToken(user) }) : reply(401, { error: 'Invalid credentials' });
}
}
+ if (url.origin === PRODUCTION_CODES) return codes(url, method, init);
return platform.fetch(input, init);
return platform.fetch(input, init);
};
};
}
}
@@ -9,13 +9,16 @@
@@ -9,13 +9,16 @@
* 2. discovery GET /.well-known/openvibe, as any origin may
* 2. discovery GET /.well-known/openvibe, as any origin may
* 3. project a new project (sandbox) through /api/v1/projects (openvibe-sdk/projects)
* 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
* 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
+ * 5. grants media.object.upload|read, events.app.publish|read and codes.release.manage,
+ * approved at once for the project owner inside the sandbox allowance
* 6. media examples/media-uploader: upload a small file, read it back, delete it
* 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
* 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
+ * 8. release as the app, on OpenVibe.Codes: a sandbox release of itself goes draft →
+ * published → deprecated → revoked, checked in the public list at each step
+ * (roadmap WS-N task 5, the app release flow in production)
+ * 9. credentials rotate with no overlap, revoke the first credential; the old secret is refused
* at /oauth/token and the new one works
* at /oauth/token and the new one works
- * 9. cleanup archive the project (always attempted once it exists); the app's secret is
+ * 10. cleanup archive the project (always attempted once it exists); the app's secret is
* refused afterwards
* refused afterwards
*
*
* The same flow runs in CI against openvibe-sdk/testing's mock platform (scripts/developer-path-mock.js,
* The same flow runs in CI against openvibe-sdk/testing's mock platform (scripts/developer-path-mock.js,
@@ -28,7 +31,7 @@
@@ -28,7 +31,7 @@
*
*
* Credentials come from the environment only (or ./.env). It refuses to start without them, and
* 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
* 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
+ * openvibe.network). Optional: OV_NETWORK_URL, OV_CODES_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
* 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.
* step passed, 1 when one failed, 2 when it refused to start.
*/
*/
@@ -45,8 +48,9 @@ const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, '
@@ -45,8 +48,9 @@ const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, '
const { createSubscriber, createCursorStore, loadConfig: subscriberConfig } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
const { createSubscriber, createCursorStore, loadConfig: subscriberConfig } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
const PRODUCTION_NETWORK = 'https://openvibe.network';
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 PRODUCTION_CODES = 'https://openvibe.codes';
+const GRANTS = ['media.object.upload', 'media.object.read', 'events.app.publish', 'events.app.read', 'codes.release.manage'];
+const STEPS = ['account', 'discovery', 'project', 'app', 'grants', 'media', 'events', 'release', 'credentials', 'cleanup'];
const STEP_TIMEOUT_MS = 120000;
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 withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };
@@ -66,6 +70,17 @@ async function postJson(fetchImpl, url, body) {
@@ -66,6 +70,17 @@ async function postJson(fetchImpl, url, body) {
return { status: res.status, body: await res.json().catch(() => ({})) };
return { status: res.status, body: await res.json().catch(() => ({})) };
}
}
+/** POST /oauth/token client_credentials → the app's access token for `audience`, or throws. */
+async function appToken(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(() => ({}));
+ if (!res.ok || typeof body.access_token !== 'string') throw new Error(`no token for ${audience}: ${res.status} ${body.error || ''}`);
+ return body.access_token;
+}
+
/** POST /oauth/token client_credentials → { status, error } (the token itself is not kept). */
/** POST /oauth/token client_credentials → { status, error } (the token itself is not kept). */
async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
const res = await fetchImpl(`${network}/oauth/token`, {
const res = await fetchImpl(`${network}/oauth/token`, {
@@ -77,12 +92,13 @@ async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
@@ -77,12 +92,13 @@ async function tryToken(fetchImpl, network, clientId, clientSecret, audience) {
}
}
/**
/**
- * Runs the nine steps. account: { token } | { username, password, register }. onSecret(value) is
+ * Runs the ten 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.
* 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 }
* → { 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() }) {
+async function runDeveloperPath({ network = PRODUCTION_NETWORK, codes = PRODUCTION_CODES, fetch: fetchImpl = globalThis.fetch, account, log = (m) => console.log(m), onSecret = () => {}, now = () => new Date() }) {
network = network.replace(/\/+$/, '');
network = network.replace(/\/+$/, '');
+ codes = codes.replace(/\/+$/, '');
const say = (m) => log(` ${m}`);
const say = (m) => log(` ${m}`);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-developer-path-'));
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 st = { token: null, projects: null, projectId: null, appId: null, secret: null, credentialId: null, newSecret: null };
@@ -203,6 +219,39 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, fetch: fetchImpl
@@ -203,6 +219,39 @@ async function runDeveloperPath({ network = PRODUCTION_NETWORK, fetch: fetchImpl
say(`pulled ${topics[0]} with events.app.read: ${n} event(s), found ${mine.id} at seq ${mine.seq}`);
say(`pulled ${topics[0]} with events.app.read: ${n} event(s), found ${mine.id} at seq ${mine.seq}`);
});
});
+ await step('release', async () => {
+ const token = await appToken(fetchImpl, network, st.appId, st.secret, 'openvibe.codes');
+ onSecret(token);
+ const call = async (method, p, body, auth = true) => {
+ const res = await fetchImpl(`${codes}/api/v1${p}`, {
+ method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(auth ? { Authorization: `Bearer ${token}` } : {}) },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const out = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(`${method} ${p}: ${res.status} ${out.code || ''} ${out.detail || ''}`.trim());
+ return out;
+ };
+ const listed = async (id) => ((await call('GET', `/apps/${st.appId}/releases`, null, false)).releases || []).find((r) => r.id === id);
+ const version = `1.0.${Math.floor(now().getTime() / 1000)}`;
+ const manifest = {
+ id: st.appId, name: 'developer-path', version, publisher: { type: 'app', id: st.appId }, project_id: st.projectId, environment: 'sandbox', capabilities: [],
+ compatibility: { contracts: `^${require('openvibe-contracts/package.json').version}`, sdk: `^${require('openvibe-sdk/package.json').version}` },
+ };
+ const draft = (await call('POST', `/apps/${st.appId}/releases`, { kind: 'app', manifest, notes: 'OpenVibe developer path check', publish: false })).release;
+ if (!draft || draft.status !== 'draft') throw new Error(`the new release is ${draft && draft.status}, not draft`);
+ if (await listed(draft.id)) throw new Error('a draft is listed publicly');
+ say(`draft ${draft.id} (${version})`);
+ const steps = [['publish', {}, 'published', true], ['deprecate', { reason: 'developer path check' }, 'deprecated', true], ['revoke', { reason: 'developer path check done' }, 'revoked', false]];
+ for (const [action, body, want, shown] of steps) {
+ const r = await call('POST', `/releases/${draft.id}/${action}`, body);
+ const status = (r.release || r).status;
+ if (status !== want) throw new Error(`${action} left it ${status}, not ${want}`);
+ const pub = await listed(draft.id);
+ if (shown ? !(pub && pub.status === want) : pub) throw new Error(`after ${action} the public list ${pub ? `shows it ${pub.status}` : 'does not show it'}`);
+ say(`${action}: ${want}${shown ? ', in the public list' : ', gone from the public list'}`);
+ }
+ });
+
await step('credentials', async () => {
await step('credentials', async () => {
const rotated = await st.projects.credentials.rotate(st.projectId, st.appId, { overlapSeconds: 0 });
const rotated = await st.projects.credentials.rotate(st.projectId, st.appId, { overlapSeconds: 0 });
st.newSecret = rotated.credential.client_secret;
st.newSecret = rotated.credential.client_secret;
@@ -272,7 +321,8 @@ async function main(argv = process.argv.slice(2), env = process.env) {
@@ -272,7 +321,8 @@ async function main(argv = process.argv.slice(2), env = process.env) {
const onSecret = maskOutput();
const onSecret = maskOutput();
console.log(`OpenVibe developer path against ${network}\n`);
console.log(`OpenVibe developer path against ${network}\n`);
const started = Date.now();
const started = Date.now();
- const result = await runDeveloperPath({ network, account, onSecret });
+ const codes = (env.OV_CODES_URL || PRODUCTION_CODES).replace(/\/+$/, '');
+ const result = await runDeveloperPath({ network, codes, account, onSecret });
// --result <file>: a JSON record for the scheduled production run (OpenVibe.Host openvibe-devpath.timer,
// --result <file>: a JSON record for the scheduled production run (OpenVibe.Host openvibe-devpath.timer,
// shown on openvibe.network/status): step names and outcomes only — never an error text, URL, id or secret.
// shown on openvibe.network/status): step names and outcomes only — never an error text, URL, id or secret.
const out = argv.indexOf('--result');
const out = argv.indexOf('--result');
@@ -291,7 +341,7 @@ function writeResult(file, result, { network, started = Date.now(), now = Date.n
@@ -291,7 +341,7 @@ function writeResult(file, result, { network, started = Date.now(), now = Date.n
return record;
return record;
}
}
-module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK };
+module.exports = { runDeveloperPath, writeResult, GRANTS, STEPS, PRODUCTION_NETWORK, PRODUCTION_CODES };
if (require.main === module) {
if (require.main === module) {
main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });
main().then((code) => { process.exitCode = code; }, (err) => { console.error(`developer-path: ${describeError(err)}`); process.exitCode = 1; });