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)
Changes
@@@ -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));@@@ -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 ?"),@@@ -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');@@@ -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');@@@ -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",@@@ -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"}@@@ -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",@@@ -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",@@@ -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",@@@ -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",
@@ -71,11 +71,16 @@ function createOwnerResolver(o) {
@@ -71,11 +71,16 @@ function createOwnerResolver(o) {
const claims = parts.length === 3 ? b64json(parts[1]) : null;
const claims = parts.length === 3 ? b64json(parts[1]) : null;
const looksLikePrincipal = claims && (Array.isArray(claims.cap) || /^(svc|app|mod):/.test(String(claims.sub || '')));
const looksLikePrincipal = claims && (Array.isArray(claims.cap) || /^(svc|app|mod):/.test(String(claims.sub || '')));
if (looksLikePrincipal) {
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] };
if (!r.ok) return { error: [401, r.code, r.reason] };
const denied = principalCapability(r.claims, CAPS[action] || CAPS.read);
const denied = principalCapability(r.claims, CAPS[action] || CAPS.read);
if (denied) return { error: denied };
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;
const sid = req.user && req.user.subject_id;
@@ -163,7 +168,7 @@ function mountJobRoutes(app, o) {
@@ -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 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);
const key = req.headers['idempotency-key'] != null ? String(req.headers['idempotency-key']) : (b.idempotency_key != null ? String(b.idempotency_key) : null);
try {
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}`);
res.set('Location', `/api/v1/jobs/${job.id}`);
if (replayed) res.set('Idempotent-Replayed', 'true');
if (replayed) res.set('Idempotent-Replayed', 'true');
return res.status(replayed ? 200 : 202).json(system.view(job));
return res.status(replayed ? 200 : 202).json(system.view(job));
@@ -35,7 +35,8 @@ CREATE TABLE IF NOT EXISTS tool_jobs (
@@ -35,7 +35,8 @@ CREATE TABLE IF NOT EXISTS tool_jobs (
updated_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
started_at INTEGER,
started_at INTEGER,
finished_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 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);
CREATE INDEX IF NOT EXISTS tool_jobs_state ON tool_jobs(state, id);
@@ -57,10 +58,14 @@ function createStore(db) {
@@ -57,10 +58,14 @@ function createStore(db) {
db.pragma('journal_mode = WAL');
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
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 = {
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 = ?'),
get: db.prepare('SELECT * FROM tool_jobs WHERE id = ?'),
byIdem: db.prepare('SELECT * FROM tool_jobs WHERE owner = ? AND idempotency_key = ?'),
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 ?"),
nextQueued: db.prepare("SELECT * FROM tool_jobs WHERE state = 'queued' ORDER BY id LIMIT ?"),
@@ -164,7 +164,12 @@ function createJobSystem(o) {
@@ -164,7 +164,12 @@ function createJobSystem(o) {
* submit({ owner, type, input, files: [{ path | buffer, name, mime, size }], idempotencyKey, ttlMs })
* 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.
* → { 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');
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)));
const discard = () => Promise.all(files.map(f => (f.path ? fsp.unlink(f.path).catch(() => {}) : null)));
try {
try {
@@ -192,7 +197,8 @@ function createJobSystem(o) {
@@ -192,7 +197,8 @@ function createJobSystem(o) {
return { job: prior, replayed: true };
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 id = `job_${contracts.ids.ulid()}`;
const inDir = path.join(jobDir(id), 'in');
const inDir = path.join(jobDir(id), 'in');
@@ -209,7 +215,7 @@ function createJobSystem(o) {
@@ -209,7 +215,7 @@ function createJobSystem(o) {
store.insert({
store.insert({
id, type, type_version: def.version, owner, input_json: JSON.stringify(input), files_json: JSON.stringify(stored),
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,
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) {
} catch (err) {
@@ -324,7 +330,7 @@ function createJobSystem(o) {
@@ -324,7 +330,7 @@ function createJobSystem(o) {
const size = fs.statSync(local).size;
const size = fs.statSync(local).size;
const sha256 = await sha256File(local);
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 };
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 {
try {
const progress = progressFn(row.id, entry);
const progress = progressFn(row.id, entry);
progress(99, 'Storing the result');
progress(99, 'Storing the result');
@@ -363,7 +363,7 @@ async function readSse(url, { headers = {}, stop = () => false, ms = 5000 } = {}
@@ -363,7 +363,7 @@ async function readSse(url, { headers = {}, stop = () => false, ms = 5000 } = {}
// ── Service/app principals (tools.job.* capabilities) ───
// ── Service/app principals (tools.job.* capabilities) ───
s = await satellite(path.join(root, 'f'));
s = await satellite(path.join(root, 'f'));
const token = (claims) => contracts.serviceAuth.signServiceToken({
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,
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);
}, privateKey);
const dev = client(s.base, { bearer: token({}) });
const dev = client(s.base, { bearer: token({}) });
@@ -381,6 +381,22 @@ async function readSse(url, { headers = {}, stop = () => false, ms = 5000 } = {}
@@ -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');
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}`);
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');
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();
await s.close();
console.log('jobs (shared runtime): all checks passed');
console.log('jobs (shared runtime): all checks passed');
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -1158,9 +1158,9 @@
@@ -1158,9 +1158,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"uuid": "^11.1.0"
"uuid": "^11.1.0"
}
}
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -1237,9 +1237,9 @@
@@ -1237,9 +1237,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"pdf-lib": "^1.17.1",
"pdf-lib": "^1.17.1",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",
@@ -1728,9 +1728,9 @@
@@ -1728,9 +1728,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.3.0",
"sharp": "^0.33.5",
"sharp": "^0.33.5",
"to-ico": "^1.1.5",
"to-ico": "^1.1.5",