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