openvibers/openvibe.tools Commits

Jobs accept developer-app sandbox tokens (apps only; other sandbox tokens 401), store env, sandbox jobs capped at 2 active and 30 min, results never sent to Media; openvibe-contracts v0.28.0 (sandbox refused by default elsewhere)

OP OpenVibers committed 23 Sept 2026 · 43f71e45a5 · 10 files, +58 −26


Changes

ddiff --git a/apps/_shared/jobs/http.js b/apps/_shared/jobs/http.js
iindex f49c791..c40320d 100644
---- a/apps/_shared/jobs/http.js
++++ b/apps/_shared/jobs/http.js
@@@ -71,11 +71,16 @@ function createOwnerResolver(o) {
const claims = parts.length === 3 ? b64json(parts[1]) : null;
const looksLikePrincipal = claims && (Array.isArray(claims.cap) || /^(svc|app|mod):/.test(String(claims.sub || '')));
if (looksLikePrincipal) {
-- const r = contracts.serviceAuth.verifyServiceToken(token, { publicKey: o.getPublicKey(), issuer: o.issuer, audience });
++ // Job routes are the only Tools routes that take sandbox tokens (developer apps, ADR-014), and
++ // only from apps: a sandbox token that is not an app's is refused.
++ const r = contracts.serviceAuth.verifyServiceToken(token, { publicKey: o.getPublicKey(), issuer: o.issuer, audience, acceptSandbox: true });
++ if (r.ok && r.claims.env === 'sandbox' && !(r.claims.actor_type === 'app' && /^app:/.test(String(r.claims.sub || '')))) {
++ return { error: [401, 'token.sandbox_refused', 'sandbox tokens are accepted only from developer apps'] };
++ }
if (!r.ok) return { error: [401, r.code, r.reason] };
const denied = principalCapability(r.claims, CAPS[action] || CAPS.read);
if (denied) return { error: denied };
-- return { owner: r.claims.sub, kind: 'principal', claims: r.claims };
++ return { owner: r.claims.sub, kind: 'principal', claims: r.claims, env: r.claims.env === 'sandbox' ? 'sandbox' : 'production' };
}
}
const sid = req.user && req.user.subject_id;
@@@ -163,7 +168,7 @@ function mountJobRoutes(app, o) {
const files = list.map(f => ({ path: f.path, buffer: f.path ? undefined : f.buffer, name: f.originalname, mime: f.mimetype, size: f.size }));
const key = req.headers['idempotency-key'] != null ? String(req.headers['idempotency-key']) : (b.idempotency_key != null ? String(b.idempotency_key) : null);
try {
-- const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w) });
++ const { job, replayed } = await system.submit({ owner: w.owner, type: String(b.type || ''), input, files, idempotencyKey: key, ttlMs: ttlMs(req, w), env: w.env || 'production' });
res.set('Location', `/api/v1/jobs/${job.id}`);
if (replayed) res.set('Idempotent-Replayed', 'true');
return res.status(replayed ? 200 : 202).json(system.view(job));
ddiff --git a/apps/_shared/jobs/store.js b/apps/_shared/jobs/store.js
iindex 84ff001..b326868 100644
---- a/apps/_shared/jobs/store.js
++++ b/apps/_shared/jobs/store.js
@@@ -35,7 +35,8 @@ CREATE TABLE IF NOT EXISTS tool_jobs (
updated_at INTEGER NOT NULL,
started_at INTEGER,
finished_at INTEGER,
-- expires_at INTEGER
++ expires_at INTEGER,
++ env TEXT NOT NULL DEFAULT 'production' CHECK (env IN ('production','sandbox'))
);
CREATE UNIQUE INDEX IF NOT EXISTS tool_jobs_idem ON tool_jobs(owner, idempotency_key) WHERE idempotency_key IS NOT NULL;
CREATE INDEX IF NOT EXISTS tool_jobs_state ON tool_jobs(state, id);
@@@ -57,10 +58,14 @@ function createStore(db) {
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
++ // Databases created before developer-app sandboxes: add the env column.
++ if (!db.prepare('PRAGMA table_info(tool_jobs)').all().some((c) => c.name === 'env')) {
++ db.exec("ALTER TABLE tool_jobs ADD COLUMN env TEXT NOT NULL DEFAULT 'production' CHECK (env IN ('production','sandbox'))");
++ }
const q = {
-- insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at)
-- VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now)`),
++ insert: db.prepare(`INSERT INTO tool_jobs (id, type, type_version, owner, state, input_json, files_json, idempotency_key, request_hash, max_attempts, ttl_ms, created_at, updated_at, env)
++ VALUES (@id, @type, @type_version, @owner, 'queued', @input_json, @files_json, @idempotency_key, @request_hash, @max_attempts, @ttl_ms, @now, @now, @env)`),
get: db.prepare('SELECT * FROM tool_jobs WHERE id = ?'),
byIdem: db.prepare('SELECT * FROM tool_jobs WHERE owner = ? AND idempotency_key = ?'),
nextQueued: db.prepare("SELECT * FROM tool_jobs WHERE state = 'queued' ORDER BY id LIMIT ?"),
ddiff --git a/apps/_shared/jobs/system.js b/apps/_shared/jobs/system.js
iindex d975764..9b5d8d7 100644
---- a/apps/_shared/jobs/system.js
++++ b/apps/_shared/jobs/system.js
@@@ -164,7 +164,12 @@ function createJobSystem(o) {
* submit({ owner, type, input, files: [{ path | buffer, name, mime, size }], idempotencyKey, ttlMs })
* → { job (row), replayed }. Temp input files are consumed (moved) on accept and deleted on replay.
*/
-- async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR }) {
++ // Developer-app sandbox jobs: fewer at a time, kept briefly, results never leave this server.
++ const SANDBOX_MAX_ACTIVE = Math.max(1, o.sandboxMaxActivePerOwner || 2);
++ const SANDBOX_TTL_MS = 30 * 60 * 1000;
++ async function submit({ owner, type, input = {}, files = [], idempotencyKey = null, ttlMs = HOUR, env = 'production' }) {
++ if (env !== 'sandbox') env = 'production';
++ if (env === 'sandbox') ttlMs = Math.min(ttlMs, SANDBOX_TTL_MS);
if (stopped) throw new JobError(503, 'tools.job.unavailable', 'The job system is shutting down');
const discard = () => Promise.all(files.map(f => (f.path ? fsp.unlink(f.path).catch(() => {}) : null)));
try {
@@@ -192,7 +197,8 @@ function createJobSystem(o) {
return { job: prior, replayed: true };
}
}
-- if (store.activeForOwner(owner) >= maxActivePerOwner) throw new JobError(429, 'tools.job.too_many_active', `At most ${maxActivePerOwner} unfinished jobs at a time; wait for one to finish or cancel one`);
++ const limit = env === 'sandbox' ? Math.min(SANDBOX_MAX_ACTIVE, maxActivePerOwner) : maxActivePerOwner;
++ if (store.activeForOwner(owner) >= limit) throw new JobError(429, 'tools.job.too_many_active', `At most ${limit} unfinished jobs at a time; wait for one to finish or cancel one`);
const id = `job_${contracts.ids.ulid()}`;
const inDir = path.join(jobDir(id), 'in');
@@@ -209,7 +215,7 @@ function createJobSystem(o) {
store.insert({
id, type, type_version: def.version, owner, input_json: JSON.stringify(input), files_json: JSON.stringify(stored),
idempotency_key: idempotencyKey == null ? null : String(idempotencyKey), request_hash: requestHash,
-- max_attempts: def.maxAttempts, ttl_ms: ttlMs, now: Date.now(),
++ max_attempts: def.maxAttempts, ttl_ms: ttlMs, now: Date.now(), env,
});
})();
} catch (err) {
@@@ -324,7 +330,7 @@ function createJobSystem(o) {
const size = fs.statSync(local).size;
const sha256 = await sha256File(local);
const rec = { name: String(f.name || `result-${i}`).slice(0, 200), mime: f.mime || 'application/octet-stream', size, sha256, storage: 'local', media: null, _path: local };
-- if (media && !entry.reason) {
++ if (media && !entry.reason && row.env !== 'sandbox') {
try {
const progress = progressFn(row.id, entry);
progress(99, 'Storing the result');
ddiff --git a/apps/_shared/test/jobs.test.js b/apps/_shared/test/jobs.test.js
iindex 43e9873..1ddf559 100644
---- a/apps/_shared/test/jobs.test.js
++++ b/apps/_shared/test/jobs.test.js
@@@ -363,7 +363,7 @@ async function readSse(url, { headers = {}, stop = () => false, ms = 5000 } = {}
// ── Service/app principals (tools.job.* capabilities) ───
s = await satellite(path.join(root, 'f'));
const token = (claims) => contracts.serviceAuth.signServiceToken({
-- iss: ISSUER, sub: 'app:app_01JCCCCCCCCCCCCCCCCCCCCCCC', actor_type: 'app', aud: ['openvibe.tools'],
++ iss: ISSUER, sub: 'app:app_01JCCCCCCCCCCCCCCCCCCCCCCC', actor_type: 'app', aud: ['openvibe.tools'], project_id: 'prj_01JCCCCCCCCCCCCCCCCCCCCCCC', env: 'production',
cap: ['tools.job.create', 'tools.job.read'], iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomBytes(8).toString('hex'), ...claims,
}, privateKey);
const dev = client(s.base, { bearer: token({}) });
@@@ -381,6 +381,22 @@ async function readSse(url, { headers = {}, stop = () => false, ms = 5000 } = {}
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.wrong_audience');
r = await client(s.base, { bearer: token({ sub: 'app:app_01JDDDDDDDDDDDDDDDDDDDDDDD' }) })(`/api/v1/jobs/${devJob}`);
assert.strictEqual(r.status, 404, 'another app cannot read it');
++
++ // ── Developer-app sandbox (ADR-014): only apps, kept apart, never sent to Media ───
++ const sbx = client(s.base, { bearer: token({ env: 'sandbox', cap: ['tools.job.create', 'tools.job.read', 'tools.job.cancel'] }) });
++ r = await sbx('/api/v1/jobs', json({ type: 'test.upper', input: { text: 'sandbox' } }));
++ assert.strictEqual(r.status, 202, 'a sandbox app token can submit');
++ const sbxJob = r.body.id;
++ await until(async () => (await sbx(`/api/v1/jobs/${sbxJob}`)).body.state === 'succeeded', 'sandbox job');
++ assert.strictEqual(s.system.get(sbxJob).env, 'sandbox', 'the job remembers its environment');
++ assert.ok(s.system.get(sbxJob).ttl_ms <= 30 * 60 * 1000, 'sandbox jobs are kept briefly');
++ r = await client(s.base, { bearer: token({ sub: 'app:app_01JDDDDDDDDDDDDDDDDDDDDDDD', env: 'sandbox' }) })(`/api/v1/jobs/${sbxJob}`);
++ assert.strictEqual(r.status, 404, 'another sandbox app cannot read it');
++ r = await client(s.base, { bearer: contracts.serviceAuth.signServiceToken({
++ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.tools'], cap: ['tools.job.create'], env: 'sandbox',
++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: crypto.randomBytes(8).toString('hex'),
++ }, privateKey) })('/api/v1/jobs', json({ type: 'test.upper', input: {} }));
++ assert.strictEqual(r.status, 401, 'a sandbox token that is not an app is refused'); assert.strictEqual(r.body.code, 'token.sandbox_refused');
await s.close();
console.log('jobs (shared runtime): all checks passed');
ddiff --git a/apps/audio/package-lock.json b/apps/audio/package-lock.json
iindex 6aef9bd..6699d7c 100644
---- a/apps/audio/package-lock.json
++++ b/apps/audio/package-lock.json
@@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
}
@@@ -1158,9 +1158,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-M1UIDUF1nK2v94S++Sqw+d1G9mj3UKaPB8vse6UWIKZy9IAdA7Yu6DsTK0mJ0UXKeOVLLWuxeQX4G+/HtV9REg==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/apps/audio/package.json b/apps/audio/package.json
iindex 0801e05..ab6f7c8 100644
---- a/apps/audio/package.json
++++ b/apps/audio/package.json
@@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
}
ddiff --git a/apps/docs/package-lock.json b/apps/docs/package-lock.json
iindex d79fc6c..86592f8 100644
---- a/apps/docs/package-lock.json
++++ b/apps/docs/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
@@@ -1237,9 +1237,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-M1UIDUF1nK2v94S++Sqw+d1G9mj3UKaPB8vse6UWIKZy9IAdA7Yu6DsTK0mJ0UXKeOVLLWuxeQX4G+/HtV9REg==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/apps/docs/package.json b/apps/docs/package.json
iindex a8d9b6b..7134358 100644
---- a/apps/docs/package.json
++++ b/apps/docs/package.json
@@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
ddiff --git a/apps/img/package-lock.json b/apps/img/package-lock.json
iindex f9490eb..f605b8e 100644
---- a/apps/img/package-lock.json
++++ b/apps/img/package-lock.json
@@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
@@@ -1728,9 +1728,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.11.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
-- "integrity": "sha512-M1UIDUF1nK2v94S++Sqw+d1G9mj3UKaPB8vse6UWIKZy9IAdA7Yu6DsTK0mJ0UXKeOVLLWuxeQX4G+/HtV9REg==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/apps/img/package.json b/apps/img/package.json
iindex 4d434b9..d2ea8e8 100644
---- a/apps/img/package.json
++++ b/apps/img/package.json
@@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.11.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",