openvibers/openvibe.contracts Commits

v0.3.0: openvibe-contracts-check CLI for service CI (contract range, enforced capabilities exist and are owned by the service, validated contract ids exist); network/live manifests accept >=0.2.0 <1.0.0

OP OpenVibers committed 22 Sept 2026 · d2e9872123 · 6 files, +119 −5


Changes

ddiff --git a/README.md b/README.md
iindex 696d9d27..52eed7b1 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
--**Status:** alpha, v0.2 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
++**Status:** alpha, v0.3 (Wave 1). Schemas, manifests and helpers are real and tested; no service validates against them in production yet.
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@@ -42,6 +42,8 @@ router.post('/coins/credit', contracts.serviceAuth.requireCapability('network.co
A request that presents a Bearer token is judged only on that token; a bad token is never rescued by a legacy header.
++Contract check for a service's CI (v0.3): `npx openvibe-contracts-check --service network --src server`. It fails if the installed contracts are outside the service manifest's `contractRanges`, if the code enforces a capability (`requireCapability('…')`, `guard('…')`, `capabilities.check(…, '…')`) that is undefined, retired or owned by another service, or if it validates against a contract id that doesn't exist.
++
TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json). All schemas in one file: `generated/json-schema/bundle.json`.
## What's in v0.1
ddiff --git a/bin/check-service.js b/bin/check-service.js
nnew file mode 100755
iindex 00000000..23cf5328
---- /dev/null
++++ b/bin/check-service.js
@@@ -0,0 +1,83 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Contract check for a consuming service, run in that service's CI:
++ *
++ * npx openvibe-contracts-check --service network --src server [--src lib]
++ *
++ * Fails when
++ * - the installed openvibe-contracts version is outside the service manifest's contractRanges
++ * - the code guards a capability (requireCapability('x') / guard('x') / capabilities.check(.., 'x'))
++ * that the contracts don't define, that is retired, or that another service owns
++ * - the code validates against a contract id (validate('x') / assertValid('x')) that doesn't exist
++ * Prints what it checked so a green run says something.
++ */
++const fs = require('fs');
++const path = require('path');
++const contracts = require('..');
++const pkg = require('../package.json');
++
++const args = process.argv.slice(2);
++const opt = (name) => { const out = []; args.forEach((a, i) => { if (a === `--${name}`) out.push(args[i + 1]); }); return out; };
++const service = opt('service')[0];
++const srcDirs = opt('src').length ? opt('src') : ['server'];
++if (!service) { console.error('usage: openvibe-contracts-check --service <id> [--src <dir>]...'); process.exit(2); }
++
++const problems = [];
++const manifest = contracts.services.get(service);
++if (!manifest) problems.push(`no service manifest "${service}" in openvibe-contracts ${pkg.version}`);
++
++// ^x.y.z / >=x.y.z <a.b.c, the only range forms the manifests use.
++function satisfies(version, range) {
++ const v = version.split('.').map(Number);
++ const cmp = (a, b) => { for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i]; return 0; };
++ return String(range).split('||').some(alt => alt.trim().split(/\s+/).every(part => {
++ const m = part.match(/^(\^|>=|<=|>|<|=)?(\d+)\.(\d+)\.(\d+)$/);
++ if (!m) return false;
++ const r = [Number(m[2]), Number(m[3]), Number(m[4])];
++ const c = cmp(v, r);
++ switch (m[1]) {
++ case '^': return c >= 0 && (r[0] > 0 ? v[0] === r[0] : v[1] === r[1]);
++ case '>=': return c >= 0; case '<=': return c <= 0; case '>': return c > 0; case '<': return c < 0;
++ default: return c === 0;
++ }
++ }));
++}
++const range = manifest && manifest.contractRanges && manifest.contractRanges['openvibe-contracts'];
++if (manifest && range && !satisfies(pkg.version, range)) problems.push(`installed openvibe-contracts ${pkg.version} is outside the manifest range ${range}`);
++
++const files = [];
++for (const d of srcDirs) (function walk(dir) {
++ let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { problems.push(`--src ${dir} does not exist`); return; }
++ for (const e of ents) {
++ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
++ const p = path.join(dir, e.name);
++ if (e.isDirectory()) walk(p); else if (/\.[cm]?[jt]s$/.test(e.name)) files.push(p);
++ }
++})(d);
++
++const CAP_RE = /\b(?:requireCapability|guard)\(\s*['"]([a-z][a-z0-9_.]+)['"]|capabilities\.check\([^,]+,\s*['"]([a-z][a-z0-9_.]+)['"]/g;
++const CONTRACT_RE = /\b(?:validate|assertValid)\(\s*['"]([a-z][a-z0-9-]*\.[a-z0-9-]+(?:@\d+)?)['"]/g;
++const used = new Map();
++const refs = new Map();
++for (const f of files) {
++ const text = fs.readFileSync(f, 'utf8');
++ let m;
++ while ((m = CAP_RE.exec(text))) { const id = m[1] || m[2]; (used.get(id) || used.set(id, []).get(id)).push(path.relative(process.cwd(), f)); }
++ while ((m = CONTRACT_RE.exec(text))) { (refs.get(m[1]) || refs.set(m[1], []).get(m[1])).push(path.relative(process.cwd(), f)); }
++}
++for (const [id, where] of used) {
++ const cap = contracts.capabilities.get(id);
++ if (!cap) problems.push(`${where[0]}: capability ${id} is not defined in openvibe-contracts ${pkg.version}`);
++ else if (cap.status === 'retired') problems.push(`${where[0]}: capability ${id} is retired`);
++ else if (cap.owner !== service) problems.push(`${where[0]}: capability ${id} is owned by ${cap.owner}, not ${service}`);
++ else if (manifest && !manifest.capabilities.includes(id)) problems.push(`${where[0]}: ${id} is enforced here but missing from the ${service} manifest`);
++}
++for (const [ref, where] of refs) {
++ try { contracts.resolve(ref); } catch (e) { problems.push(`${where[0]}: ${e.message}`); }
++}
++
++console.log(`openvibe-contracts-check ${service}: contracts ${pkg.version}${range ? ` (manifest wants ${range})` : ''}, ${files.length} files, ` +
++ `${used.size} capabilities enforced [${[...used.keys()].sort().join(', ')}], ${refs.size} contract ids validated [${[...refs.keys()].sort().join(', ')}]`);
++if (problems.length) { console.error(` ${problems.length} problem(s):\n - ${problems.join('\n - ')}`); process.exit(1); }
++console.log(' ok');
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
iindex a146a381..056d83e7 100644
---- a/manifests/services/live.json
++++ b/manifests/services/live.json
@@@ -1,7 +1,7 @@
{
"version": "0.1.0",
"contractRanges": {
-- "openvibe-contracts": "^0.2.0"
++ "openvibe-contracts": ">=0.2.0 <1.0.0"
},
"id": "live",
"name": "OpenVibe.Live",
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 81ff1aed..1331c575 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -1,7 +1,7 @@
{
"version": "0.1.0",
"contractRanges": {
-- "openvibe-contracts": "^0.2.0"
++ "openvibe-contracts": ">=0.2.0 <1.0.0"
},
"id": "network",
"name": "OpenVibe.Network",
ddiff --git a/package.json b/package.json
iindex 04f3d9c3..cdb415bb 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.2.0",
++ "version": "0.3.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
@@@ -10,7 +10,8 @@
"contracts/",
"manifests/",
"compatibility/",
-- "generated/"
++ "generated/",
++ "bin/"
],
"scripts": {
"test": "node test/run.js",
@@@ -27,5 +28,8 @@
},
"devDependencies": {
"json-schema-to-typescript": "^15.0.4"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
}
}
ddiff --git a/test/run.js b/test/run.js
iindex 24d38cb2..609344cd 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -156,6 +156,31 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces
const failing = serviceAuth.createTokenClient({ tokenUrl: 'x', clientId: 'live', clientSecret: 'bad', audience: 'a', fetchImpl: async () => ({ ok: false, status: 401, json: async () => ({ error: 'invalid_client' }) }) });
await assert.rejects(failing.getToken(), /401: invalid_client/);
++// ── openvibe-contracts-check (the CLI services run in CI) ─────────────────
++{
++ const os = require('os');
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ovc-check-'));
++ const bin = path.join(ROOT, 'bin/check-service.js');
++ const check = (files, service = 'network') => {
++ fs.rmSync(path.join(tmp, 'src'), { recursive: true, force: true });
++ fs.mkdirSync(path.join(tmp, 'src'), { recursive: true });
++ for (const [n, body] of Object.entries(files)) fs.writeFileSync(path.join(tmp, 'src', n), body);
++ try { return { code: 0, out: execFileSync(process.execPath, [bin, '--service', service, '--src', 'src'], { cwd: tmp, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) }; }
++ catch (e) { return { code: e.status, out: String(e.stdout) + String(e.stderr) }; }
++ };
++ let c = check({ 'a.js': "router.post('/x', guard('network.coins.credit'), h); validate('identity.subject-ref@1', v);" });
++ ok(c.code === 0 && /1 capabilities enforced \[network.coins.credit\]/.test(c.out), 'owned capability passes: ' + c.out);
++ c = check({ 'a.js': "requireCapability('network.coins.mint')" });
++ ok(c.code === 1 && /not defined/.test(c.out), 'unknown capability fails');
++ c = check({ 'a.js': "requireCapability('media.object.upload')" });
++ ok(c.code === 1 && /owned by media, not network/.test(c.out), 'another service\'s capability fails');
++ c = check({ 'a.js': "assertValid('identity.subject-ref@9', v)" });
++ ok(c.code === 1 && /not @9/.test(c.out), 'unknown contract major fails');
++ c = check({ 'a.js': '' }, 'nope');
++ ok(c.code === 1 && /no service manifest/.test(c.out), 'unknown service fails');
++ fs.rmSync(tmp, { recursive: true, force: true });
++}
++
// ── Generated output and compatibility gate ──────────────────────────────
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });