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
Changes
@@@ -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.coA 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.1nnew file mode 100755@@@ -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');@@@ -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",@@@ -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",@@@ -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"}}@@@ -156,6 +156,31 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forcesconst 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' });
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Machine-readable contracts for the whole OpenVibe network.
> 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).
**Plan:** OpenVibe Development Roadmap, Wave 1 (implementation plan rev 3, §3.1 and §18.11).
**License:** AGPL-3.0 (same as every OpenVibe service).
**License:** AGPL-3.0 (same as every OpenVibe service).
@@ -42,6 +42,8 @@ router.post('/coins/credit', contracts.serviceAuth.requireCapability('network.co
@@ -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.
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`.
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
## What's in v0.1
new file mode 100755
new file mode 100755
@@ -0,0 +1,83 @@
@@ -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');
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"version": "0.1.0",
"version": "0.1.0",
"contractRanges": {
"contractRanges": {
- "openvibe-contracts": "^0.2.0"
+ "openvibe-contracts": ">=0.2.0 <1.0.0"
},
},
"id": "live",
"id": "live",
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"version": "0.1.0",
"version": "0.1.0",
"contractRanges": {
"contractRanges": {
- "openvibe-contracts": "^0.2.0"
+ "openvibe-contracts": ">=0.2.0 <1.0.0"
},
},
"id": "network",
"id": "network",
"name": "OpenVibe.Network",
"name": "OpenVibe.Network",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"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.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
"contracts/",
"contracts/",
"manifests/",
"manifests/",
"compatibility/",
"compatibility/",
- "generated/"
+ "generated/",
+ "bin/"
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
@@ -27,5 +28,8 @@
@@ -27,5 +28,8 @@
},
},
"devDependencies": {
"devDependencies": {
"json-schema-to-typescript": "^15.0.4"
"json-schema-to-typescript": "^15.0.4"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
}
}
}
}
@@ -156,6 +156,31 @@ client.invalidate(); await client.getToken(); ok(calls === 2, 'invalidate forces
@@ -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' }) }) });
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/);
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 ──────────────────────────────
// ── Generated output and compatibility gate ──────────────────────────────
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/generate.js'), '--check'], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });
execFileSync(process.execPath, [path.join(ROOT, 'scripts/compat.js')], { stdio: 'inherit' });