openvibers/openvibe.media Commits
Service-principal tokens on the tenant API; token-only 'community' tenant; paste export bundle for Community's importer
Changes
@@@ -15,3 +15,5 @@ jobs:- run: npm ci --no-audit --no-fund- run: npm test++ - name: contract check (openvibe-contracts)++ run: npx openvibe-contracts-check --service media --src server@@@ -16,6 +16,7 @@"dotenv": "^16.4.5","express": "^4.19.2","multer": "^1.4.5-lts.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0","openvibe-shared": "file:./vendor/openvibe-shared","sharp": "^0.33.4"}@@@ -866,6 +867,39 @@"node": ">= 0.6"}},++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },"node_modules/append-field": {"version": "1.0.0","resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",@@@ -1377,6 +1411,28 @@"url": "https://opencollective.com/express"}},++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.8",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },"node_modules/file-uri-to-path": {"version": "1.0.0","resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",@@@ -1598,6 +1654,12 @@"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==","license": "MIT"},++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },"node_modules/jsonwebtoken": {"version": "9.0.3","resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",@@@ -1891,6 +1953,22 @@"wrappy": "1"}},++ "node_modules/openvibe-contracts": {++ "version": "0.5.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",++ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },"node_modules/openvibe-shared": {"resolved": "vendor/openvibe-shared","link": true@@@ -2042,6 +2120,15 @@"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==","license": "MIT"},++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },"node_modules/safe-buffer": {"version": "5.2.1","resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",@@@ -11,6 +11,7 @@},"license": "MIT","dependencies": {++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0","@aws-sdk/client-s3": "^3.600.0","@aws-sdk/lib-storage": "^3.600.0","@aws-sdk/s3-request-presigner": "^3.600.0",nnew file mode 100755@@@ -0,0 +1,61 @@++#!/usr/bin/env node++'use strict';++/**++ * Export an app's pastes, comments and likes as a canonical bundle for OpenVibe.Community's importer++ * (roadmap Wave 5: Community becomes the paste authority; Media keeps only the screenshot bytes).++ *++ * node scripts/export-pastes.js --app live --out /tmp/pastes.json [--since-id N] [--db ./data/media.db]++ *++ * Read-only (opens the database readonly). Deterministic for the same data: rows ordered by id, keys++ * in a fixed order, sha256 over { pastes, comments, likes }. IP addresses are never exported.++ * --since-id exports pastes with id > N plus every comment/like on any paste (they are small and the++ * importer upserts them), so an incremental run after cutover catches late writes.++ */++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const Database = require('better-sqlite3');++++const args = process.argv.slice(2);++const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };++const app = arg('app', 'live');++const out = arg('out');++const sinceId = Number(arg('since-id', 0)) || 0;++const dbPath = path.resolve(arg('db', process.env.DB_PATH || './data/media.db'));++const publicBase = String(process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, '');++if (!out) { console.error('usage: export-pastes.js --app <id> --out <file> [--since-id N] [--db path]'); process.exit(2); }++++const db = new Database(dbPath, { readonly: true, fileMustExist: true });++const json = (s) => { if (s == null || s === '') return null; try { return JSON.parse(s); } catch { return null; } };++const tags = (s) => { const v = json(s); return Array.isArray(v) ? v : (typeof s === 'string' && s ? s.split(',').map(x => x.trim()).filter(Boolean) : null); };++++const pastes = db.prepare(`SELECT * FROM pastes WHERE app_id = ? AND id > ? ORDER BY id`).all(app, sinceId).map(p => ({++ id: p.id, slug: p.slug, user_id: p.user_id, type: p.type, title: p.title, content: p.content, language: p.language,++ visibility: p.visibility, stream_id: p.stream_id,++ screenshot_url: p.screenshot_path ? `${publicBase}/p/${p.slug}/screenshot` : null,++ metadata: json(p.metadata), burn_after_read: p.burn_after_read, forked_from: p.forked_from, pinned: p.pinned,++ views: p.views, unique_views: p.unique_views || 0, copies: p.copies, likes: p.likes, is_nsfw: p.is_nsfw,++ ai_summary: p.ai_summary, ai_tags: tags(p.ai_tags), ai_analyzed_at: p.ai_analyzed_at,++ created_at: p.created_at, updated_at: p.updated_at,++}));++const comments = db.prepare(`SELECT c.* FROM paste_comments c JOIN pastes p ON p.id = c.paste_id WHERE p.app_id = ? ORDER BY c.id`).all(app).map(c => ({++ id: c.id, paste_id: c.paste_id, user_id: c.user_id, parent_id: c.parent_id, anon_name: c.anon_name,++ message: c.message, is_deleted: c.is_deleted, created_at: c.created_at, updated_at: c.updated_at,++}));++const likes = db.prepare(`SELECT l.* FROM paste_likes l JOIN pastes p ON p.id = l.paste_id WHERE p.app_id = ? ORDER BY l.paste_id, l.user_id`).all(app).map(l => ({++ paste_id: l.paste_id, user_id: l.user_id, created_at: l.created_at,++}));++db.close();++++const body = { pastes, comments, likes };++const bundle = {++ format: 'openvibe.media.pastes-export', version: 1, app,++ generated_at: new Date().toISOString(), since_id: sinceId,++ max_id: pastes.length ? pastes[pastes.length - 1].id : sinceId,++ sha256: crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'),++ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },++ ...body,++};++fs.writeFileSync(out, JSON.stringify(bundle));++fs.chmodSync(out, 0o600); // paste content can be private/unlisted++console.log(JSON.stringify({ out, app, since_id: sinceId, max_id: bundle.max_id, counts: bundle.counts, sha256: bundle.sha256 }));@@@ -16,6 +16,7 @@'use strict';const crypto = require('crypto');++const { serviceAuth, capabilities, http } = require('openvibe-contracts');const db = require('./db/database');const config = require('./config');@@@ -92,6 +93,27 @@ function verifyUserJwt(token) {}}++/** A Network-issued service token for this service (sub svc:/app:/mod:), or null. */++function verifyServiceToken(token) {++ if (!_networkPublicKeyPem) return null;++ const r = serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media' });++ return r.ok ? r.claims : null;++}++++function problem(res, status, code, detail) {++ return http.sendProblem(res, status, code, { detail });++}++++/**++ * Tenants that exist only to be reached with service tokens (no API key is ever issued):++ * OpenVibe.Community stores screenshot bytes here under its own namespace.++ */++function ensureTokenOnlyApps() {++ for (const [appId, name] of [['community', 'OpenVibe.Community']]) {++ if (!db.getApp(appId)) db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)", [appId, name, 10 * 1024 ** 3]);++ }++}++// ── App key check (constant-time) ────────────────────────────function checkAppKey(app, presentedKey) {@@@ -138,7 +160,7 @@ function actingUserId(req) {* @param {boolean} [opts.allowUser=false] also accept a Network user JWT* (browser endpoints); Origin, when present, must be allow-listed.*/--function tenantAuth({ allowUser = false } = {}) {++function tenantAuth({ allowUser = false, capability = null } = {}) {return (req, res, next) => {const appId = String(req.params.app || '').trim();const app = appId ? db.getApp(appId) : null;@@@ -166,6 +188,21 @@ function tenantAuth({ allowUser = false } = {}) {return res.status(403).json({ error: 'API key not valid for this app' });}++ // 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on++ // routes that name the capability they perform, and only for the :app namespaces the token was++ // granted. It carries the app's authority for that one action — no acting user.++ if (token && token.split('.').length === 3) {++ const svc = verifyServiceToken(token);++ if (svc) {++ if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');++ const c = capabilities.check(svc, capability, { namespace: appId });++ if (!c.allowed) return problem(res, 403, c.code, c.reason);++ req.authType = 'app';++ req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };++ return next();++ }++ }++// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's// users is calling. It names the account by the Network's id; every user_id// stored here is in the app's own space. They are different numbers over the@@@ -280,6 +317,9 @@ function seedApps() {module.exports = {tenantAuth,++ ensureTokenOnlyApps,++ verifyServiceToken,++ _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },tenantCors,optionalIdentity,// verifyUserJwt stays internal: it proves a token is a genuine Network credential,@@@ -72,7 +72,7 @@ function sha256File(filePath) {}// ── Upload ───────────────────────────────────────────────────--router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {++router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload' }), upload.single('file'), async (req, res) => {try {if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });@@@ -22,6 +22,7 @@ for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, codb.getDb(); // init schema (WAL)auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS++auth.ensureTokenOnlyApps(); // tenants reached only with Network service tokens (community)auth.startJwksRefresh();const app = express();nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++// Service-principal tokens on Media's tenant API (roadmap Wave 1/5): accepted only on routes that name++// their capability and only for granted :app namespaces; the token-only 'community' tenant; and the++// paste export bundle Community imports.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++const { execFileSync } = require('child_process');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-svc-'));++process.env.DB_PATH = path.join(tmp, 'media.db');++process.env.FILES_PATH = path.join(tmp, 'files');++process.env.OV_NETWORK_URL = 'https://openvibe.network';++process.env.MEDIA_PUBLIC_URL = 'https://media.test';++++const db = require('../server/db/database');++const auth = require('../server/auth');++const { serviceAuth } = require('openvibe-contracts');++const express = require('express');++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++auth._setNetworkPublicKeyForTests(keys.publicKey);++db.upsertApp({ app_id: 'live', api_key: 'live-key' });++auth.ensureTokenOnlyApps();++auth.ensureTokenOnlyApps(); // idempotent++const community = db.getApp('community');++assert.ok(community && community.api_key_hash === '' && community.quota_bytes > 0, 'token-only community tenant with a quota');++++const now = Math.floor(Date.now() / 1000);++const tok = (over = {}) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_test12345', ...over }, keys.privateKey);++++const app = express();++app.use('/api/v1/:app/files', require('../server/files/routes'));++const server = http.createServer(app);++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const upload = (appId, bearer) => {++ const fd = new FormData();++ fd.append('file', new Blob([Buffer.from('\x89PNG\r\n\x1a\nfake')], { type: 'image/png' }), 'shot.png');++ return fetch(`${base}/api/v1/${appId}/files`, { method: 'POST', headers: bearer ? { authorization: `Bearer ${bearer}` } : {}, body: fd })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ };++++ let r = await upload('community', tok());++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.ok(r.body.key && r.body.url, 'upload answers key + url');++ r = await upload('live', tok());++ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.namespace_denied', 'a community token cannot write the live namespace');++ r = await upload('community', tok({ cap: ['media.object.read'] }));++ assert.strictEqual(r.body.code, 'capability.denied');++ r = await upload('community', tok({ aud: ['openvibe.network'] }));++ assert.strictEqual(r.status, 401, 'wrong audience is not a service credential here');++ r = await upload('community', serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_forged123' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey));++ assert.strictEqual(r.status, 401, 'forged token refused');++ r = await upload('community', '');++ assert.strictEqual(r.status, 401);++ r = await upload('community', 'live-key');++ assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");++ const list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });++ assert.strictEqual(list.status, 403, 'routes that name no capability refuse service tokens');++ server.close();++++ // ── Paste export bundle ──++ const d = db.getDb();++ d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata, ip_address, ai_tags) VALUES (1, 'live', 'a-a-1', 5, 'paste', 'A', 'x', 'public', NULL, '{\"k\":1}', '10.0.0.1', 'one,two'), (2, 'live', 'b-b-2', NULL, 'screenshot', 'B', '', 'unlisted', '/data/s.png', NULL, '10.0.0.2', NULL), (3, 'tools', 'c-c-3', 1, 'paste', 'C', 'y', 'public', NULL, NULL, NULL, NULL)").run();++ d.prepare("INSERT INTO paste_comments (id, paste_id, user_id, anon_name, message, ip_address) VALUES (1, 1, NULL, 'anon', 'hi', '10.0.0.3')").run();++ d.prepare('INSERT INTO paste_likes (paste_id, user_id) VALUES (1, 7)').run();++ const outFile = path.join(tmp, 'bundle.json');++ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--db', process.env.DB_PATH], { env: { ...process.env } });++ const b = JSON.parse(fs.readFileSync(outFile, 'utf8'));++ assert.strictEqual(b.format, 'openvibe.media.pastes-export');++ assert.deepStrictEqual(b.counts, { pastes: 2, comments: 1, likes: 1 }, 'only the requested app');++ assert.strictEqual(b.sha256, crypto.createHash('sha256').update(JSON.stringify({ pastes: b.pastes, comments: b.comments, likes: b.likes })).digest('hex'));++ assert.ok(!JSON.stringify(b).includes('10.0.0.'), 'no IP addresses leave Media');++ assert.strictEqual(b.pastes[1].screenshot_url, 'https://media.test/p/b-b-2/screenshot');++ assert.deepStrictEqual(b.pastes[0].metadata, { k: 1 });++ assert.deepStrictEqual(b.pastes[0].ai_tags, ['one', 'two']);++ assert.strictEqual((fs.statSync(outFile).mode & 0o777), 0o600, 'bundle is private');++ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--since-id', '1', '--db', process.env.DB_PATH]);++ const inc = JSON.parse(fs.readFileSync(outFile, 'utf8'));++ assert.deepStrictEqual(inc.pastes.map((p) => p.id), [2], 'incremental export');++ assert.strictEqual(inc.counts.comments, 1, 'comments always included');++++ fs.rmSync(tmp, { recursive: true, force: true });++ console.log('service tokens + paste export: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -15,3 +15,5 @@ jobs:
@@ -15,3 +15,5 @@ jobs:
- run: npm ci --no-audit --no-fund
- run: npm ci --no-audit --no-fund
- run: npm test
- run: npm test
+ - name: contract check (openvibe-contracts)
+ run: npx openvibe-contracts-check --service media --src server
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"dotenv": "^16.4.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express": "^4.19.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.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared",
"openvibe-shared": "file:./vendor/openvibe-shared",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
}
}
@@ -866,6 +867,39 @@
@@ -866,6 +867,39 @@
"node": ">= 0.6"
"node": ">= 0.6"
}
}
},
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/append-field": {
"node_modules/append-field": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
@@ -1377,6 +1411,28 @@
@@ -1377,6 +1411,28 @@
"url": "https://opencollective.com/express"
"url": "https://opencollective.com/express"
}
}
},
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-uri-to-path": {
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1598,6 +1654,12 @@
@@ -1598,6 +1654,12 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/jsonwebtoken": {
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1891,6 +1953,22 @@
@@ -1891,6 +1953,22 @@
"wrappy": "1"
"wrappy": "1"
}
}
},
},
+ "node_modules/openvibe-contracts": {
+ "version": "0.5.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
+ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
"resolved": "vendor/openvibe-shared",
"resolved": "vendor/openvibe-shared",
"link": true
"link": true
@@ -2042,6 +2120,15 @@
@@ -2042,6 +2120,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
"license": "MIT"
},
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/safe-buffer": {
"node_modules/safe-buffer": {
"version": "5.2.1",
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/lib-storage": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
new file mode 100755
new file mode 100755
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Export an app's pastes, comments and likes as a canonical bundle for OpenVibe.Community's importer
+ * (roadmap Wave 5: Community becomes the paste authority; Media keeps only the screenshot bytes).
+ *
+ * node scripts/export-pastes.js --app live --out /tmp/pastes.json [--since-id N] [--db ./data/media.db]
+ *
+ * Read-only (opens the database readonly). Deterministic for the same data: rows ordered by id, keys
+ * in a fixed order, sha256 over { pastes, comments, likes }. IP addresses are never exported.
+ * --since-id exports pastes with id > N plus every comment/like on any paste (they are small and the
+ * importer upserts them), so an incremental run after cutover catches late writes.
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const Database = require('better-sqlite3');
+
+const args = process.argv.slice(2);
+const arg = (name, dflt) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : dflt; };
+const app = arg('app', 'live');
+const out = arg('out');
+const sinceId = Number(arg('since-id', 0)) || 0;
+const dbPath = path.resolve(arg('db', process.env.DB_PATH || './data/media.db'));
+const publicBase = String(process.env.MEDIA_PUBLIC_URL || 'https://openvibe.media').replace(/\/$/, '');
+if (!out) { console.error('usage: export-pastes.js --app <id> --out <file> [--since-id N] [--db path]'); process.exit(2); }
+
+const db = new Database(dbPath, { readonly: true, fileMustExist: true });
+const json = (s) => { if (s == null || s === '') return null; try { return JSON.parse(s); } catch { return null; } };
+const tags = (s) => { const v = json(s); return Array.isArray(v) ? v : (typeof s === 'string' && s ? s.split(',').map(x => x.trim()).filter(Boolean) : null); };
+
+const pastes = db.prepare(`SELECT * FROM pastes WHERE app_id = ? AND id > ? ORDER BY id`).all(app, sinceId).map(p => ({
+ id: p.id, slug: p.slug, user_id: p.user_id, type: p.type, title: p.title, content: p.content, language: p.language,
+ visibility: p.visibility, stream_id: p.stream_id,
+ screenshot_url: p.screenshot_path ? `${publicBase}/p/${p.slug}/screenshot` : null,
+ metadata: json(p.metadata), burn_after_read: p.burn_after_read, forked_from: p.forked_from, pinned: p.pinned,
+ views: p.views, unique_views: p.unique_views || 0, copies: p.copies, likes: p.likes, is_nsfw: p.is_nsfw,
+ ai_summary: p.ai_summary, ai_tags: tags(p.ai_tags), ai_analyzed_at: p.ai_analyzed_at,
+ created_at: p.created_at, updated_at: p.updated_at,
+}));
+const comments = db.prepare(`SELECT c.* FROM paste_comments c JOIN pastes p ON p.id = c.paste_id WHERE p.app_id = ? ORDER BY c.id`).all(app).map(c => ({
+ id: c.id, paste_id: c.paste_id, user_id: c.user_id, parent_id: c.parent_id, anon_name: c.anon_name,
+ message: c.message, is_deleted: c.is_deleted, created_at: c.created_at, updated_at: c.updated_at,
+}));
+const likes = db.prepare(`SELECT l.* FROM paste_likes l JOIN pastes p ON p.id = l.paste_id WHERE p.app_id = ? ORDER BY l.paste_id, l.user_id`).all(app).map(l => ({
+ paste_id: l.paste_id, user_id: l.user_id, created_at: l.created_at,
+}));
+db.close();
+
+const body = { pastes, comments, likes };
+const bundle = {
+ format: 'openvibe.media.pastes-export', version: 1, app,
+ generated_at: new Date().toISOString(), since_id: sinceId,
+ max_id: pastes.length ? pastes[pastes.length - 1].id : sinceId,
+ sha256: crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex'),
+ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },
+ ...body,
+};
+fs.writeFileSync(out, JSON.stringify(bundle));
+fs.chmodSync(out, 0o600); // paste content can be private/unlisted
+console.log(JSON.stringify({ out, app, since_id: sinceId, max_id: bundle.max_id, counts: bundle.counts, sha256: bundle.sha256 }));
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
'use strict';
'use strict';
const crypto = require('crypto');
const crypto = require('crypto');
+const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const db = require('./db/database');
const db = require('./db/database');
const config = require('./config');
const config = require('./config');
@@ -92,6 +93,27 @@ function verifyUserJwt(token) {
@@ -92,6 +93,27 @@ function verifyUserJwt(token) {
}
}
}
}
+/** A Network-issued service token for this service (sub svc:/app:/mod:), or null. */
+function verifyServiceToken(token) {
+ if (!_networkPublicKeyPem) return null;
+ const r = serviceAuth.verifyServiceToken(token, { publicKey: _networkPublicKeyPem, issuer: config.network.url, audience: 'openvibe.media' });
+ return r.ok ? r.claims : null;
+}
+
+function problem(res, status, code, detail) {
+ return http.sendProblem(res, status, code, { detail });
+}
+
+/**
+ * Tenants that exist only to be reached with service tokens (no API key is ever issued):
+ * OpenVibe.Community stores screenshot bytes here under its own namespace.
+ */
+function ensureTokenOnlyApps() {
+ for (const [appId, name] of [['community', 'OpenVibe.Community']]) {
+ if (!db.getApp(appId)) db.run("INSERT INTO apps (app_id, name, api_key_hash, quota_bytes) VALUES (?, ?, '', ?)", [appId, name, 10 * 1024 ** 3]);
+ }
+}
+
// ── App key check (constant-time) ────────────────────────────
// ── App key check (constant-time) ────────────────────────────
function checkAppKey(app, presentedKey) {
function checkAppKey(app, presentedKey) {
@@ -138,7 +160,7 @@ function actingUserId(req) {
@@ -138,7 +160,7 @@ function actingUserId(req) {
* @param {boolean} [opts.allowUser=false] also accept a Network user JWT
* @param {boolean} [opts.allowUser=false] also accept a Network user JWT
* (browser endpoints); Origin, when present, must be allow-listed.
* (browser endpoints); Origin, when present, must be allow-listed.
*/
*/
-function tenantAuth({ allowUser = false } = {}) {
+function tenantAuth({ allowUser = false, capability = null } = {}) {
return (req, res, next) => {
return (req, res, next) => {
const appId = String(req.params.app || '').trim();
const appId = String(req.params.app || '').trim();
const app = appId ? db.getApp(appId) : null;
const app = appId ? db.getApp(appId) : null;
@@ -166,6 +188,21 @@ function tenantAuth({ allowUser = false } = {}) {
@@ -166,6 +188,21 @@ function tenantAuth({ allowUser = false } = {}) {
return res.status(403).json({ error: 'API key not valid for this app' });
return res.status(403).json({ error: 'API key not valid for this app' });
}
}
+ // 1b) A service-principal token from OpenVibe.Network (roadmap Wave 1, ADR-003). Accepted only on
+ // routes that name the capability they perform, and only for the :app namespaces the token was
+ // granted. It carries the app's authority for that one action — no acting user.
+ if (token && token.split('.').length === 3) {
+ const svc = verifyServiceToken(token);
+ if (svc) {
+ if (!capability) return problem(res, 403, 'capability.denied', 'service tokens are not accepted on this route');
+ const c = capabilities.check(svc, capability, { namespace: appId });
+ if (!c.allowed) return problem(res, 403, c.code, c.reason);
+ req.authType = 'app';
+ req.principal = { sub: svc.sub, cap: svc.cap, jti: svc.jti };
+ return next();
+ }
+ }
+
// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's
// 2) A Network user JWT is a real credential, but it cannot say WHICH of an app's
// users is calling. It names the account by the Network's id; every user_id
// users is calling. It names the account by the Network's id; every user_id
// stored here is in the app's own space. They are different numbers over the
// stored here is in the app's own space. They are different numbers over the
@@ -280,6 +317,9 @@ function seedApps() {
@@ -280,6 +317,9 @@ function seedApps() {
module.exports = {
module.exports = {
tenantAuth,
tenantAuth,
+ ensureTokenOnlyApps,
+ verifyServiceToken,
+ _setNetworkPublicKeyForTests(pem) { _networkPublicKeyPem = pem; },
tenantCors,
tenantCors,
optionalIdentity,
optionalIdentity,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
// verifyUserJwt stays internal: it proves a token is a genuine Network credential,
@@ -72,7 +72,7 @@ function sha256File(filePath) {
@@ -72,7 +72,7 @@ function sha256File(filePath) {
}
}
// ── Upload ───────────────────────────────────────────────────
// ── Upload ───────────────────────────────────────────────────
-router.post('/', tenantAuth({ allowUser: true }), upload.single('file'), async (req, res) => {
+router.post('/', tenantAuth({ allowUser: true, capability: 'media.object.upload' }), upload.single('file'), async (req, res) => {
try {
try {
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded (multipart field: file)' });
@@ -22,6 +22,7 @@ for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, co
@@ -22,6 +22,7 @@ for (const dir of [config.vod.path, config.vod.clipsPath, config.pastes.path, co
db.getDb(); // init schema (WAL)
db.getDb(); // init schema (WAL)
auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
auth.seedApps(); // upsert MEDIA_APPS_SEED / MEDIA_APP_KEYS
+auth.ensureTokenOnlyApps(); // tenants reached only with Network service tokens (community)
auth.startJwksRefresh();
auth.startJwksRefresh();
const app = express();
const app = express();
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+// Service-principal tokens on Media's tenant API (roadmap Wave 1/5): accepted only on routes that name
+// their capability and only for granted :app namespaces; the token-only 'community' tenant; and the
+// paste export bundle Community imports.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+const { execFileSync } = require('child_process');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-svc-'));
+process.env.DB_PATH = path.join(tmp, 'media.db');
+process.env.FILES_PATH = path.join(tmp, 'files');
+process.env.OV_NETWORK_URL = 'https://openvibe.network';
+process.env.MEDIA_PUBLIC_URL = 'https://media.test';
+
+const db = require('../server/db/database');
+const auth = require('../server/auth');
+const { serviceAuth } = require('openvibe-contracts');
+const express = require('express');
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+auth._setNetworkPublicKeyForTests(keys.publicKey);
+db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+auth.ensureTokenOnlyApps();
+auth.ensureTokenOnlyApps(); // idempotent
+const community = db.getApp('community');
+assert.ok(community && community.api_key_hash === '' && community.quota_bytes > 0, 'token-only community tenant with a quota');
+
+const now = Math.floor(Date.now() / 1000);
+const tok = (over = {}) => serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_test12345', ...over }, keys.privateKey);
+
+const app = express();
+app.use('/api/v1/:app/files', require('../server/files/routes'));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const upload = (appId, bearer) => {
+ const fd = new FormData();
+ fd.append('file', new Blob([Buffer.from('\x89PNG\r\n\x1a\nfake')], { type: 'image/png' }), 'shot.png');
+ return fetch(`${base}/api/v1/${appId}/files`, { method: 'POST', headers: bearer ? { authorization: `Bearer ${bearer}` } : {}, body: fd })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ };
+
+ let r = await upload('community', tok());
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.ok(r.body.key && r.body.url, 'upload answers key + url');
+ r = await upload('live', tok());
+ assert.strictEqual(r.status, 403); assert.strictEqual(r.body.code, 'capability.namespace_denied', 'a community token cannot write the live namespace');
+ r = await upload('community', tok({ cap: ['media.object.read'] }));
+ assert.strictEqual(r.body.code, 'capability.denied');
+ r = await upload('community', tok({ aud: ['openvibe.network'] }));
+ assert.strictEqual(r.status, 401, 'wrong audience is not a service credential here');
+ r = await upload('community', serviceAuth.signServiceToken({ iss: 'https://openvibe.network', sub: 'svc:community', actor_type: 'service', aud: ['openvibe.media'], cap: ['media.object.upload'], ns: ['community'], iat: now, exp: now + 300, jti: 'tok_forged123' }, crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey));
+ assert.strictEqual(r.status, 401, 'forged token refused');
+ r = await upload('community', '');
+ assert.strictEqual(r.status, 401);
+ r = await upload('community', 'live-key');
+ assert.strictEqual(r.status, 403, "live's key is not valid for the community tenant");
+ const list = await fetch(`${base}/api/v1/community/files`, { headers: { authorization: `Bearer ${tok({ cap: ['media.object.upload', 'media.object.read'] })}` } });
+ assert.strictEqual(list.status, 403, 'routes that name no capability refuse service tokens');
+ server.close();
+
+ // ── Paste export bundle ──
+ const d = db.getDb();
+ d.prepare("INSERT INTO pastes (id, app_id, slug, user_id, type, title, content, visibility, screenshot_path, metadata, ip_address, ai_tags) VALUES (1, 'live', 'a-a-1', 5, 'paste', 'A', 'x', 'public', NULL, '{\"k\":1}', '10.0.0.1', 'one,two'), (2, 'live', 'b-b-2', NULL, 'screenshot', 'B', '', 'unlisted', '/data/s.png', NULL, '10.0.0.2', NULL), (3, 'tools', 'c-c-3', 1, 'paste', 'C', 'y', 'public', NULL, NULL, NULL, NULL)").run();
+ d.prepare("INSERT INTO paste_comments (id, paste_id, user_id, anon_name, message, ip_address) VALUES (1, 1, NULL, 'anon', 'hi', '10.0.0.3')").run();
+ d.prepare('INSERT INTO paste_likes (paste_id, user_id) VALUES (1, 7)').run();
+ const outFile = path.join(tmp, 'bundle.json');
+ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--db', process.env.DB_PATH], { env: { ...process.env } });
+ const b = JSON.parse(fs.readFileSync(outFile, 'utf8'));
+ assert.strictEqual(b.format, 'openvibe.media.pastes-export');
+ assert.deepStrictEqual(b.counts, { pastes: 2, comments: 1, likes: 1 }, 'only the requested app');
+ assert.strictEqual(b.sha256, crypto.createHash('sha256').update(JSON.stringify({ pastes: b.pastes, comments: b.comments, likes: b.likes })).digest('hex'));
+ assert.ok(!JSON.stringify(b).includes('10.0.0.'), 'no IP addresses leave Media');
+ assert.strictEqual(b.pastes[1].screenshot_url, 'https://media.test/p/b-b-2/screenshot');
+ assert.deepStrictEqual(b.pastes[0].metadata, { k: 1 });
+ assert.deepStrictEqual(b.pastes[0].ai_tags, ['one', 'two']);
+ assert.strictEqual((fs.statSync(outFile).mode & 0o777), 0o600, 'bundle is private');
+ execFileSync(process.execPath, [path.join(__dirname, '../scripts/export-pastes.js'), '--app', 'live', '--out', outFile, '--since-id', '1', '--db', process.env.DB_PATH]);
+ const inc = JSON.parse(fs.readFileSync(outFile, 'utf8'));
+ assert.deepStrictEqual(inc.pastes.map((p) => p.id), [2], 'incremental export');
+ assert.strictEqual(inc.counts.comments, 1, 'comments always included');
+
+ fs.rmSync(tmp, { recursive: true, force: true });
+ console.log('service tokens + paste export: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });