openvibers/openvibe.media Commits
Media takes part in account export and deletion (roadmap WS-B task 7, ADR-033; openvibe-contracts 0.54.0 -> 0.71.0). POST /internal/events takes network.account.export_requested and network.account.deleted (the boot subscriptions now include both). server/account-data.js applies each once per export or deletion (account_data_events) and answers after Network took the part or confirmation, so a failure is redelivered without erasing twice.
Changes
@@@ -16,7 +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.54.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0","sharp": "^0.33.4"@@@ -1919,9 +1919,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.54.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",-- "integrity": "sha512-jOYCWRtK6LWvsc1u1XL85GzH93EMm4qXjAHOnZp9hP3d8kxg0pQc25yuHWLhh3tYR5e4BH2pz0opKrkpwBtu5w==",++ "version": "0.71.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",++ "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -12,7 +12,7 @@},"license": "MIT","dependencies": {-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0","@aws-sdk/client-s3": "^3.600.0","@aws-sdk/lib-storage": "^3.600.0",nnew file mode 100644@@@ -0,0 +1,162 @@++'use strict';++/**++ * Account export and deletion → Media (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at++ * POST /internal/events (server/revocations.js), signed and loopback-only, once per export or deletion++ * (account_data_events); the delivery is answered after Network took the part or the confirmation, so a failure is++ * redelivered without erasing twice.++ *++ * network.account.export_requested Media's part (POST /internal/account-exports/:id/parts with a service token):++ * objects.json (every media object the subject owns, with its public URL while++ * it has one), vods.json and clips.json (their rows, stream keys and storage keys++ * left out). The bytes are downloaded from the URLs.++ * network.account.deleted what the subject (and the accounts merged into it) own goes:++ * - native objects are soft-deleted; their bytes are purged after the retention++ * days, as for any deletion;++ * - VODs, clips and files are removed like their delete routes do (the bytes++ * everywhere, the row, and the object through the row-delete trigger);++ * - the thumbnails of those VODs and clips are marked deleted.++ * Kept, and counted: media under a retention hold (ADR-006), and paste screenshots++ * and avatars, whose rows sit in the frozen paste tables Community took over.++ * Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).++ */++const fs = require('fs');++++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;++const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;++const TOPICS = ['network.account.export_requested', 'network.account.deleted'];++const SECRET_COL = /(^|_)(token|secret|hash|password|key|keys|stream_key|storage_key)($|_)/i;++const ROW_LIMIT = 5000;++++function ensureSchema(db) {++ db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome TEXT,++ sent_at TEXT,++ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))++ )`);++}++++const clean = (row) => { const o = {}; for (const [k, v] of Object.entries(row)) if (!SECRET_COL.test(k)) o[k] = v; return o; };++const inList = (xs) => `(${xs.map(() => '?').join(',')})`;++++function exportPart(db, subjects) {++ const model = require('./objects/model');++ const objs = db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY created_at DESC LIMIT ${ROW_LIMIT}`).all(...subjects);++ const objects = objs.map((o) => {++ const p = model.objectPublic(o, { locations: false });++ return { id: p.id, kind: p.kind, visibility: p.visibility, lifecycle_status: p.lifecycle_status, mime_type: p.mime_type, size_bytes: p.size_bytes,++ public_url: p.public_url, legacy_ref: p.legacy_ref, created_at: p.created_at, held: p.held };++ });++ const ids = objs.map((o) => o.id);++ const rows = (table) => (ids.length ? db.prepare(`SELECT * FROM ${table} WHERE object_id IN ${inList(ids)} ORDER BY created_at DESC`).all(...ids).map(clean) : []);++ const files = [{ name: 'objects.json', content: objects }];++ const vods = rows('vods'); const clips = rows('clips'); const plain = rows('files');++ if (vods.length) files.push({ name: 'vods.json', content: vods });++ if (clips.length) files.push({ name: 'clips.json', content: clips });++ if (plain.length) files.push({ name: 'files.json', content: plain });++ return { files, truncated: objs.length >= ROW_LIMIT ? ['objects.json'] : [], note: 'Download each file from its public_url; private ones from openvibe.media while signed in.' };++}++++/** Erase what the subjects own → { erased, retained }. Each object in its own step: a hold stops only that one. */++function erase(db, subjects) {++ const model = require('./objects/model');++ const erased = {};++ const retained = {};++ const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };++ const objs = db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY (kind = 'thumbnail'), created_at`).all(...subjects);++ for (const o of objs) {++ if (model.isHeld(o.id)) { bump(retained, 'held_media'); continue; }++ try {++ if (!o.legacy_ref) { model.softDelete(o, { by: 'account_deleted' }); bump(erased, 'objects'); continue; }++ if (o.kind === 'vod') {++ const vod = db.prepare('SELECT * FROM vods WHERE object_id = ?').get(o.id);++ if (!vod) continue;++ if (vod.file_path) {++ require('./vod/vod-storage').deleteVodObjects(vod).catch((e) => console.warn(`[AccountData] VOD ${vod.id} remote cleanup:`, e.message));++ for (const p of [vod.file_path, vod.master_file_path]) { try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* gone */ } }++ }++ db.prepare('DELETE FROM vods WHERE id = ?').run(vod.id);++ bump(erased, 'vods');++ } else if (o.kind === 'clip') {++ const clip = db.prepare('SELECT * FROM clips WHERE object_id = ?').get(o.id);++ if (!clip) continue;++ try { if (clip.file_path && fs.existsSync(clip.file_path)) fs.unlinkSync(clip.file_path); } catch { /* gone */ }++ if (clip.storage_provider && clip.storage_provider !== 'local' && clip.storage_key) {++ require('./vod/vod-storage').deleteVodObjects(clip).catch((e) => console.warn(`[AccountData] clip ${clip.id} remote cleanup:`, e.message));++ }++ db.prepare('DELETE FROM clips WHERE id = ?').run(clip.id);++ bump(erased, 'clips');++ } else if (o.kind === 'file') {++ const row = db.prepare('SELECT * FROM files WHERE object_id = ?').get(o.id);++ if (!row) continue;++ try { const p = require('./files/routes').filePathForKey(row); if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* gone */ }++ db.prepare('DELETE FROM files WHERE key = ?').run(row.key);++ bump(erased, 'files');++ } else if (o.kind === 'thumbnail') {++ db.prepare("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status != 'deleted'").run(o.id);++ bump(erased, 'thumbnails');++ } else {++ bump(retained, `frozen_${o.kind}s`); // screenshots and avatars: rows in the frozen paste tables++ }++ } catch (e) {++ if (/retention hold/.test(e.message)) { bump(retained, 'held_media'); continue; }++ throw e;++ }++ }++ try { require('./events').kick(); } catch { /* the relay polls anyway */ }++ return { erased, retained };++}++++async function networkCall(path, body) {++ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';++ if (!clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');++ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');++ if (!networkCall.tokens) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ networkCall.tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'media', clientSecret });++ }++ const token = await networkCall.tokens.getToken({ audience: 'openvibe.network', scope: path.includes('/account-exports/') ? 'network.account.export.contribute' : 'network.account.deletion.confirm' });++ return fetch(`${base}${path}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });++}++++/** One envelope → 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>'; throws to be redelivered. */++async function apply(ev, { db = require('./db/database').getDb(), send = networkCall } = {}) {++ if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';++ if (ev.source !== 'network') return 'ignored:source';++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ ensureSchema(db);++ if (ev.event_type === 'network.account.export_requested') {++ if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';++ const seen = db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);++ if (seen && seen.sent_at) return 'unchanged';++ const part = exportPart(db, [p.subject]);++ const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });++ const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);++ if (!outcome) throw new Error(`export part refused: ${res.status}`);++ db.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')++ .run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, objects: part.files[0].content.length }), new Date().toISOString());++ return outcome;++ }++ if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';++ let rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ let result = 'confirmed';++ if (!rec) {++ const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];++ const counts = erase(db, subjects);++ db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));++ console.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);++ rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);++ result = 'erased';++ }++ if (rec.sent_at) return 'unchanged';++ const o = JSON.parse(rec.outcome || '{}');++ const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });++ if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);++ db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);++ return result;++}++++module.exports = { apply, exportPart, erase, ensureSchema, TOPICS };@@@ -8,13 +8,14 @@* delivery moves the person's cutoff (openvibe-sdk createRevocationStore: only ever forward, source* network only); user-auth.js then refuses their older tokens, so openvibe.media's navbar shows them* signed out at once. The same endpoint takes network.subject.merged (ADR-029): a folded-in account's media objects-- * move to the survivor (server/subject-merge.js). The subscriptions (one per topic) are created at boot when++ * move to the survivor (server/subject-merge.js), and network.account.export_requested / network.account.deleted (ADR-033):++ * Media's export part, or the erasure of what the account owns (server/account-data.js). The subscriptions (one per topic) are created at boot when* EVENTS_URL, the service secret and MEDIA_INBOUND_EVENTS_SECRET are set (grant media events.subscription.manage).*/const express = require('express');const TOPIC = 'network.user.token_valid_after';--const TOPICS = [TOPIC, 'network.subject.merged'];++const TOPICS = [TOPIC, 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'];let store = null;function cutoffs() {if (!store) store = require('openvibe-sdk/auth').createRevocationStore(require('./db/database').getDb(), { table: 'token_revocations' });@@@ -36,6 +37,11 @@ function handler() {for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }stats.received++;++ // Account export and deletion (ADR-033) answer after Network took Media's part or confirmation: a failure is redelivered.++ if (require('./account-data').TOPICS.includes(d.event.event_type)) {++ return require('./account-data').apply(d.event).then((outcome) => res.json({ event_id: d.event.event_id || null, outcome }),++ (e) => { console.error(`[AccountData] ${d.event.event_type} failed:`, e.message); res.status(500).json({ error: 'not applied' }); });++ }const outcome = d.event.event_type === 'network.subject.merged' ? require('./subject-merge').apply(d.event) : cutoffs().apply(d.event);if (outcome === 'revoked') stats.revoked++;res.json({ event_id: d.event.event_id || null, outcome });nnew file mode 100644@@@ -0,0 +1,81 @@++'use strict';++// Account export and deletion → Media (roadmap WS-B task 7, ADR-033): through POST /internal/events (signed),++// network.account.export_requested sends Media's part to Network (the subject's objects, VODs and clips, no storage or++// stream keys), and network.account.deleted erases what the subject and its merged-in aliases own: native objects are++// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media and frozen paste++// screenshots are kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed++// confirmation is retried without erasing again.++// node test/account-data.test.js++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-account-data-'));++process.env.DB_PATH = path.join(tmp, 'media.db');++process.env.MEDIA_INBOUND_EVENTS_SECRET = 'm'.repeat(40);++const log = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) log(...a); };++console.warn = () => {};++const { validate } = require('openvibe-contracts');++const accountData = require('../server/account-data');++const db = require('../server/db/database').getDb();++++const DANA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', OLD = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', XENA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5';++const obj = db.prepare("INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, legacy_ref) VALUES (?, 'live', 'live', ?, ?, 'public', 'ready', ?)");++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'file', DANA, null); // native: soft-deleted++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D2', 'file', OLD, null); // native, merged-in alias: soft-deleted++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'file', DANA, null); // held: kept++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4', 'vod', DANA, 'legacy:live:vod:7'); // VOD: removed++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5', 'clip', DANA, 'legacy:live:clip:9'); // clip: removed++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', 'thumbnail', DANA, 'legacy:live:thumb:7'); // thumbnail: marked deleted++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // frozen paste: kept++obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D8', 'file', XENA, null); // someone else's++db.prepare("INSERT INTO media_holds (object_id, kind, reason) VALUES ('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'moderation', 'report under review')").run();++const vodFile = path.join(tmp, 'vod7.mp4'); fs.writeFileSync(vodFile, 'x');++const clipFile = path.join(tmp, 'clip9.mp4'); fs.writeFileSync(clipFile, 'y');++db.prepare("INSERT INTO vods (id, app_id, user_id, title, stream_key, storage_key, file_path, object_id) VALUES (7, 'live', 20, 'my stream', 'sk_secret', 'b2/secret/key', ?, 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4')").run(vodFile);++db.prepare("INSERT INTO clips (id, app_id, user_id, vod_id, title, file_path, object_id) VALUES (9, 'live', 20, 7, 'my clip', ?, 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5')").run(clipFile);++++const ev = (type, payload) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E1', event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(), payload });++++(async () => {++ const sent = [];++ let failNext = false;++ const send = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };++ try {++ assert.ok(require('../server/revocations').TOPICS.includes('network.account.deleted'), 'Media subscribes');++ // ── Export ──++ const exp = ev('network.account.export_requested', { export_id: 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1', subject: DANA, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() });++ assert.strictEqual(await accountData.apply({ ...exp, source: 'live' }, { send }), 'ignored:source');++ assert.strictEqual(await accountData.apply(exp, { send }), 'exported');++ const part = sent[0].body;++ assert.ok(validate('network.account-export-part@1', part).valid, JSON.stringify(validate('network.account-export-part@1', part).errors));++ const files = Object.fromEntries(part.files.map((f) => [f.name, f.content]));++ assert.strictEqual(files['objects.json'].length, 6, "the subject's own objects only");++ assert.deepStrictEqual(files['vods.json'].map((v) => v.title), ['my stream']);++ assert.ok(!JSON.stringify(part).includes('sk_secret') && !JSON.stringify(part).includes('b2/secret/key'), 'no stream or storage keys');++ assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');++ assert.strictEqual(sent.length, 1);++++ // ── Deletion ──++ const del = ev('network.account.deleted', { deletion_id: 'del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1', subject: DANA, aliases: [OLD], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });++ failNext = true;++ await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);++ const state = Object.fromEntries(db.prepare('SELECT id, lifecycle_status AS s FROM media_objects').all().map((r) => [r.id.slice(-2), r.s]));++ assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'ready', D8: 'ready' });++ assert.ok(!fs.existsSync(vodFile) && !fs.existsSync(clipFile), 'the bytes are gone');++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM vods').get().n + db.prepare('SELECT COUNT(*) AS n FROM clips').get().n, 0);++ assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');++ const conf = sent[1];++ assert.strictEqual(conf.path, '/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1/confirmations');++ assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));++ assert.deepStrictEqual(conf.body.erased, { objects: 2, vods: 1, clips: 1, thumbnails: 1 });++ assert.deepStrictEqual(conf.body.retained, { held_media: 1, frozen_screenshots: 1 });++ assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');++ assert.strictEqual(sent.length, 2);++ } finally {++ fs.rmSync(tmp, { recursive: true, force: true });++ }++ console.log('media account export and deletion: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -70,7 +70,7 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t};assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'created');assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'exists');-- assert.deepStrictEqual(subs.map((s) => s.topic_pattern), [revocations.TOPIC, 'network.subject.merged'], 'one subscription per topic, created once');++ assert.deepStrictEqual(subs.map((s) => s.topic_pattern), [revocations.TOPIC, 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'], 'one subscription per topic, created once');} finally {srv.close();fs.rmSync(tmp, { recursive: true, force: true });@@@ -29,6 +29,7 @@ delete process.env.MEDIA_RTMP_PULL_ALLOW;const OUTBOUND = {'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below','server/auth.js': 'Network JWKS (OV_NETWORK_URL)',++ 'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations','server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)','server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)','server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
@@ -16,7 +16,7 @@
@@ -16,7 +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.54.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.22.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
@@ -1919,9 +1919,9 @@
@@ -1919,9 +1919,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.54.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
- "integrity": "sha512-jOYCWRtK6LWvsc1u1XL85GzH93EMm4qXjAHOnZp9hP3d8kxg0pQc25yuHWLhh3tYR5e4BH2pz0opKrkpwBtu5w==",
+ "version": "0.71.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
+ "integrity": "sha512-BLS6gP/MKuwhvTdWO8UYUaOyhmf7ni0cQ8r7150o57R5TrCnzdWQEVESxfMFCGgSbm2wdKtdZwTID69nxD14Tg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.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",
new file mode 100644
new file mode 100644
@@ -0,0 +1,162 @@
@@ -0,0 +1,162 @@
+'use strict';
+/**
+ * Account export and deletion → Media (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at
+ * POST /internal/events (server/revocations.js), signed and loopback-only, once per export or deletion
+ * (account_data_events); the delivery is answered after Network took the part or the confirmation, so a failure is
+ * redelivered without erasing twice.
+ *
+ * network.account.export_requested Media's part (POST /internal/account-exports/:id/parts with a service token):
+ * objects.json (every media object the subject owns, with its public URL while
+ * it has one), vods.json and clips.json (their rows, stream keys and storage keys
+ * left out). The bytes are downloaded from the URLs.
+ * network.account.deleted what the subject (and the accounts merged into it) own goes:
+ * - native objects are soft-deleted; their bytes are purged after the retention
+ * days, as for any deletion;
+ * - VODs, clips and files are removed like their delete routes do (the bytes
+ * everywhere, the row, and the object through the row-delete trigger);
+ * - the thumbnails of those VODs and clips are marked deleted.
+ * Kept, and counted: media under a retention hold (ADR-006), and paste screenshots
+ * and avatars, whose rows sit in the frozen paste tables Community took over.
+ * Media then confirms with counts (POST /internal/account-deletions/:id/confirmations).
+ */
+const fs = require('fs');
+
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TOPICS = ['network.account.export_requested', 'network.account.deleted'];
+const SECRET_COL = /(^|_)(token|secret|hash|password|key|keys|stream_key|storage_key)($|_)/i;
+const ROW_LIMIT = 5000;
+
+function ensureSchema(db) {
+ db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome TEXT,
+ sent_at TEXT,
+ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
+ )`);
+}
+
+const clean = (row) => { const o = {}; for (const [k, v] of Object.entries(row)) if (!SECRET_COL.test(k)) o[k] = v; return o; };
+const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
+
+function exportPart(db, subjects) {
+ const model = require('./objects/model');
+ const objs = db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY created_at DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
+ const objects = objs.map((o) => {
+ const p = model.objectPublic(o, { locations: false });
+ return { id: p.id, kind: p.kind, visibility: p.visibility, lifecycle_status: p.lifecycle_status, mime_type: p.mime_type, size_bytes: p.size_bytes,
+ public_url: p.public_url, legacy_ref: p.legacy_ref, created_at: p.created_at, held: p.held };
+ });
+ const ids = objs.map((o) => o.id);
+ const rows = (table) => (ids.length ? db.prepare(`SELECT * FROM ${table} WHERE object_id IN ${inList(ids)} ORDER BY created_at DESC`).all(...ids).map(clean) : []);
+ const files = [{ name: 'objects.json', content: objects }];
+ const vods = rows('vods'); const clips = rows('clips'); const plain = rows('files');
+ if (vods.length) files.push({ name: 'vods.json', content: vods });
+ if (clips.length) files.push({ name: 'clips.json', content: clips });
+ if (plain.length) files.push({ name: 'files.json', content: plain });
+ return { files, truncated: objs.length >= ROW_LIMIT ? ['objects.json'] : [], note: 'Download each file from its public_url; private ones from openvibe.media while signed in.' };
+}
+
+/** Erase what the subjects own → { erased, retained }. Each object in its own step: a hold stops only that one. */
+function erase(db, subjects) {
+ const model = require('./objects/model');
+ const erased = {};
+ const retained = {};
+ const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
+ const objs = db.prepare(`SELECT * FROM media_objects WHERE owner_subject IN ${inList(subjects)} AND lifecycle_status != 'deleted' ORDER BY (kind = 'thumbnail'), created_at`).all(...subjects);
+ for (const o of objs) {
+ if (model.isHeld(o.id)) { bump(retained, 'held_media'); continue; }
+ try {
+ if (!o.legacy_ref) { model.softDelete(o, { by: 'account_deleted' }); bump(erased, 'objects'); continue; }
+ if (o.kind === 'vod') {
+ const vod = db.prepare('SELECT * FROM vods WHERE object_id = ?').get(o.id);
+ if (!vod) continue;
+ if (vod.file_path) {
+ require('./vod/vod-storage').deleteVodObjects(vod).catch((e) => console.warn(`[AccountData] VOD ${vod.id} remote cleanup:`, e.message));
+ for (const p of [vod.file_path, vod.master_file_path]) { try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch { /* gone */ } }
+ }
+ db.prepare('DELETE FROM vods WHERE id = ?').run(vod.id);
+ bump(erased, 'vods');
+ } else if (o.kind === 'clip') {
+ const clip = db.prepare('SELECT * FROM clips WHERE object_id = ?').get(o.id);
+ if (!clip) continue;
+ try { if (clip.file_path && fs.existsSync(clip.file_path)) fs.unlinkSync(clip.file_path); } catch { /* gone */ }
+ if (clip.storage_provider && clip.storage_provider !== 'local' && clip.storage_key) {
+ require('./vod/vod-storage').deleteVodObjects(clip).catch((e) => console.warn(`[AccountData] clip ${clip.id} remote cleanup:`, e.message));
+ }
+ db.prepare('DELETE FROM clips WHERE id = ?').run(clip.id);
+ bump(erased, 'clips');
+ } else if (o.kind === 'file') {
+ const row = db.prepare('SELECT * FROM files WHERE object_id = ?').get(o.id);
+ if (!row) continue;
+ try { const p = require('./files/routes').filePathForKey(row); if (fs.existsSync(p)) fs.unlinkSync(p); } catch { /* gone */ }
+ db.prepare('DELETE FROM files WHERE key = ?').run(row.key);
+ bump(erased, 'files');
+ } else if (o.kind === 'thumbnail') {
+ db.prepare("UPDATE media_objects SET lifecycle_status = 'deleted', deleted_at = COALESCE(deleted_at, CURRENT_TIMESTAMP), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND lifecycle_status != 'deleted'").run(o.id);
+ bump(erased, 'thumbnails');
+ } else {
+ bump(retained, `frozen_${o.kind}s`); // screenshots and avatars: rows in the frozen paste tables
+ }
+ } catch (e) {
+ if (/retention hold/.test(e.message)) { bump(retained, 'held_media'); continue; }
+ throw e;
+ }
+ }
+ try { require('./events').kick(); } catch { /* the relay polls anyway */ }
+ return { erased, retained };
+}
+
+async function networkCall(path, body) {
+ const clientSecret = process.env.OV_OAUTH_CLIENT_SECRET || '';
+ if (!clientSecret) throw new Error('OV_OAUTH_CLIENT_SECRET is not set');
+ const base = String(process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/+$/, '');
+ if (!networkCall.tokens) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ networkCall.tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId: process.env.OV_OAUTH_CLIENT_ID || 'media', clientSecret });
+ }
+ const token = await networkCall.tokens.getToken({ audience: 'openvibe.network', scope: path.includes('/account-exports/') ? 'network.account.export.contribute' : 'network.account.deletion.confirm' });
+ return fetch(`${base}${path}`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
+}
+
+/** One envelope → 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>'; throws to be redelivered. */
+async function apply(ev, { db = require('./db/database').getDb(), send = networkCall } = {}) {
+ if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
+ if (ev.source !== 'network') return 'ignored:source';
+ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ ensureSchema(db);
+ if (ev.event_type === 'network.account.export_requested') {
+ if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
+ const seen = db.prepare('SELECT sent_at FROM account_data_events WHERE id = ?').get(p.export_id);
+ if (seen && seen.sent_at) return 'unchanged';
+ const part = exportPart(db, [p.subject]);
+ const res = await send(`/internal/account-exports/${p.export_id}/parts`, { subject: p.subject, ...part });
+ const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
+ if (!outcome) throw new Error(`export part refused: ${res.status}`);
+ db.prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')
+ .run(p.export_id, 'export', p.subject, JSON.stringify({ result: outcome, objects: part.files[0].content.length }), new Date().toISOString());
+ return outcome;
+ }
+ if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
+ let rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ let result = 'confirmed';
+ if (!rec) {
+ const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
+ const counts = erase(db, subjects);
+ db.prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)').run(p.deletion_id, 'deletion', p.subject, JSON.stringify(counts));
+ console.log(`[AccountData] deletion ${p.deletion_id}: ${JSON.stringify(counts)}`);
+ rec = db.prepare('SELECT * FROM account_data_events WHERE id = ?').get(p.deletion_id);
+ result = 'erased';
+ }
+ if (rec.sent_at) return 'unchanged';
+ const o = JSON.parse(rec.outcome || '{}');
+ const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: rec.applied_at, erased: o.erased || {}, retained: o.retained || {} });
+ if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`);
+ db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?').run(new Date().toISOString(), p.deletion_id);
+ return result;
+}
+
+module.exports = { apply, exportPart, erase, ensureSchema, TOPICS };
@@ -8,13 +8,14 @@
@@ -8,13 +8,14 @@
* delivery moves the person's cutoff (openvibe-sdk createRevocationStore: only ever forward, source
* delivery moves the person's cutoff (openvibe-sdk createRevocationStore: only ever forward, source
* network only); user-auth.js then refuses their older tokens, so openvibe.media's navbar shows them
* network only); user-auth.js then refuses their older tokens, so openvibe.media's navbar shows them
* signed out at once. The same endpoint takes network.subject.merged (ADR-029): a folded-in account's media objects
* signed out at once. The same endpoint takes network.subject.merged (ADR-029): a folded-in account's media objects
- * move to the survivor (server/subject-merge.js). The subscriptions (one per topic) are created at boot when
+ * move to the survivor (server/subject-merge.js), and network.account.export_requested / network.account.deleted (ADR-033):
+ * Media's export part, or the erasure of what the account owns (server/account-data.js). The subscriptions (one per topic) are created at boot when
* EVENTS_URL, the service secret and MEDIA_INBOUND_EVENTS_SECRET are set (grant media events.subscription.manage).
* EVENTS_URL, the service secret and MEDIA_INBOUND_EVENTS_SECRET are set (grant media events.subscription.manage).
*/
*/
const express = require('express');
const express = require('express');
const TOPIC = 'network.user.token_valid_after';
const TOPIC = 'network.user.token_valid_after';
-const TOPICS = [TOPIC, 'network.subject.merged'];
+const TOPICS = [TOPIC, 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'];
let store = null;
let store = null;
function cutoffs() {
function cutoffs() {
if (!store) store = require('openvibe-sdk/auth').createRevocationStore(require('./db/database').getDb(), { table: 'token_revocations' });
if (!store) store = require('openvibe-sdk/auth').createRevocationStore(require('./db/database').getDb(), { table: 'token_revocations' });
@@ -36,6 +37,11 @@ function handler() {
@@ -36,6 +37,11 @@ function handler() {
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
for (const k of keys) { d = parseDelivery(raw, req.headers, k, { requireV2: true }); if (d) break; }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
if (!d || !d.event) { stats.refused++; return res.status(401).json({ error: 'bad signature' }); }
stats.received++;
stats.received++;
+ // Account export and deletion (ADR-033) answer after Network took Media's part or confirmation: a failure is redelivered.
+ if (require('./account-data').TOPICS.includes(d.event.event_type)) {
+ return require('./account-data').apply(d.event).then((outcome) => res.json({ event_id: d.event.event_id || null, outcome }),
+ (e) => { console.error(`[AccountData] ${d.event.event_type} failed:`, e.message); res.status(500).json({ error: 'not applied' }); });
+ }
const outcome = d.event.event_type === 'network.subject.merged' ? require('./subject-merge').apply(d.event) : cutoffs().apply(d.event);
const outcome = d.event.event_type === 'network.subject.merged' ? require('./subject-merge').apply(d.event) : cutoffs().apply(d.event);
if (outcome === 'revoked') stats.revoked++;
if (outcome === 'revoked') stats.revoked++;
res.json({ event_id: d.event.event_id || null, outcome });
res.json({ event_id: d.event.event_id || null, outcome });
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+// Account export and deletion → Media (roadmap WS-B task 7, ADR-033): through POST /internal/events (signed),
+// network.account.export_requested sends Media's part to Network (the subject's objects, VODs and clips, no storage or
+// stream keys), and network.account.deleted erases what the subject and its merged-in aliases own: native objects are
+// soft-deleted, VODs, clips and files removed with their bytes, thumbnails marked deleted; held media and frozen paste
+// screenshots are kept and counted. Media confirms with counts; a redelivery sends nothing twice, and a failed
+// confirmation is retried without erasing again.
+// node test/account-data.test.js
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-account-data-'));
+process.env.DB_PATH = path.join(tmp, 'media.db');
+process.env.MEDIA_INBOUND_EVENTS_SECRET = 'm'.repeat(40);
+const log = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) log(...a); };
+console.warn = () => {};
+const { validate } = require('openvibe-contracts');
+const accountData = require('../server/account-data');
+const db = require('../server/db/database').getDb();
+
+const DANA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', OLD = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4', XENA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5';
+const obj = db.prepare("INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, legacy_ref) VALUES (?, 'live', 'live', ?, ?, 'public', 'ready', ?)");
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D1', 'file', DANA, null); // native: soft-deleted
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D2', 'file', OLD, null); // native, merged-in alias: soft-deleted
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'file', DANA, null); // held: kept
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4', 'vod', DANA, 'legacy:live:vod:7'); // VOD: removed
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5', 'clip', DANA, 'legacy:live:clip:9'); // clip: removed
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', 'thumbnail', DANA, 'legacy:live:thumb:7'); // thumbnail: marked deleted
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D7', 'screenshot', DANA, 'legacy:live:paste:abc'); // frozen paste: kept
+obj.run('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D8', 'file', XENA, null); // someone else's
+db.prepare("INSERT INTO media_holds (object_id, kind, reason) VALUES ('med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D3', 'moderation', 'report under review')").run();
+const vodFile = path.join(tmp, 'vod7.mp4'); fs.writeFileSync(vodFile, 'x');
+const clipFile = path.join(tmp, 'clip9.mp4'); fs.writeFileSync(clipFile, 'y');
+db.prepare("INSERT INTO vods (id, app_id, user_id, title, stream_key, storage_key, file_path, object_id) VALUES (7, 'live', 20, 'my stream', 'sk_secret', 'b2/secret/key', ?, 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D4')").run(vodFile);
+db.prepare("INSERT INTO clips (id, app_id, user_id, vod_id, title, file_path, object_id) VALUES (9, 'live', 20, 7, 'my clip', ?, 'med_01J8Z3Q4R5S6T7V8W9X0Y1Z2D5')").run(clipFile);
+
+const ev = (type, payload) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E1', event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(), payload });
+
+(async () => {
+ const sent = [];
+ let failNext = false;
+ const send = async (p, body) => { if (failNext) { failNext = false; return { ok: false, status: 503 }; } sent.push({ path: p, body }); return { ok: true, status: 200 }; };
+ try {
+ assert.ok(require('../server/revocations').TOPICS.includes('network.account.deleted'), 'Media subscribes');
+ // ── Export ──
+ const exp = ev('network.account.export_requested', { export_id: 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2F1', subject: DANA, requested_at: new Date().toISOString(), deadline: new Date(Date.now() + 1800000).toISOString() });
+ assert.strictEqual(await accountData.apply({ ...exp, source: 'live' }, { send }), 'ignored:source');
+ assert.strictEqual(await accountData.apply(exp, { send }), 'exported');
+ const part = sent[0].body;
+ assert.ok(validate('network.account-export-part@1', part).valid, JSON.stringify(validate('network.account-export-part@1', part).errors));
+ const files = Object.fromEntries(part.files.map((f) => [f.name, f.content]));
+ assert.strictEqual(files['objects.json'].length, 6, "the subject's own objects only");
+ assert.deepStrictEqual(files['vods.json'].map((v) => v.title), ['my stream']);
+ assert.ok(!JSON.stringify(part).includes('sk_secret') && !JSON.stringify(part).includes('b2/secret/key'), 'no stream or storage keys');
+ assert.strictEqual(await accountData.apply(exp, { send }), 'unchanged');
+ assert.strictEqual(sent.length, 1);
+
+ // ── Deletion ──
+ const del = ev('network.account.deleted', { deletion_id: 'del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1', subject: DANA, aliases: [OLD], requested_at: new Date().toISOString(), deleted_at: new Date().toISOString() });
+ failNext = true;
+ await assert.rejects(accountData.apply(del, { send }), /confirmation refused: 503/);
+ const state = Object.fromEntries(db.prepare('SELECT id, lifecycle_status AS s FROM media_objects').all().map((r) => [r.id.slice(-2), r.s]));
+ assert.deepStrictEqual(state, { D1: 'deleted', D2: 'deleted', D3: 'ready', D4: 'deleted', D5: 'deleted', D6: 'deleted', D7: 'ready', D8: 'ready' });
+ assert.ok(!fs.existsSync(vodFile) && !fs.existsSync(clipFile), 'the bytes are gone');
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM vods').get().n + db.prepare('SELECT COUNT(*) AS n FROM clips').get().n, 0);
+ assert.strictEqual(await accountData.apply(del, { send }), 'confirmed', 'the retry confirms without erasing again');
+ const conf = sent[1];
+ assert.strictEqual(conf.path, '/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2G1/confirmations');
+ assert.ok(validate('network.account-deletion-confirmation@1', conf.body).valid, JSON.stringify(validate('network.account-deletion-confirmation@1', conf.body).errors));
+ assert.deepStrictEqual(conf.body.erased, { objects: 2, vods: 1, clips: 1, thumbnails: 1 });
+ assert.deepStrictEqual(conf.body.retained, { held_media: 1, frozen_screenshots: 1 });
+ assert.strictEqual(await accountData.apply(del, { send }), 'unchanged');
+ assert.strictEqual(sent.length, 2);
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+ console.log('media account export and deletion: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -70,7 +70,7 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
@@ -70,7 +70,7 @@ const ev = (over = {}) => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', event_t
};
};
assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'created');
assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'created');
assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'exists');
assert.strictEqual(await revocations.ensureSubscription({ fetchImpl: fake, log: { log() {} } }), 'exists');
- assert.deepStrictEqual(subs.map((s) => s.topic_pattern), [revocations.TOPIC, 'network.subject.merged'], 'one subscription per topic, created once');
+ assert.deepStrictEqual(subs.map((s) => s.topic_pattern), [revocations.TOPIC, 'network.subject.merged', 'network.account.export_requested', 'network.account.deleted'], 'one subscription per topic, created once');
} finally {
} finally {
srv.close();
srv.close();
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
@@ -29,6 +29,7 @@ delete process.env.MEDIA_RTMP_PULL_ALLOW;
@@ -29,6 +29,7 @@ delete process.env.MEDIA_RTMP_PULL_ALLOW;
const OUTBOUND = {
const OUTBOUND = {
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
'server/avatars/ingest.js': 'a person\'s picture URL: safeFetchImage, tested below',
'server/auth.js': 'Network JWKS (OV_NETWORK_URL)',
'server/auth.js': 'Network JWKS (OV_NETWORK_URL)',
+ 'server/account-data.js': 'Network internal API (OV_NETWORK_INTERNAL_URL), fixed paths: export parts and deletion confirmations',
'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',
'server/user-auth.js': 'Network OAuth and JWKS (OV_NETWORK_URL / OV_NETWORK_INTERNAL_URL)',
'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
'server/webhooks.js': 'an app\'s webhook_url, set by the operator (MEDIA_APPS_SEED)',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',
'server/thumbnails/live-frame-service.js': 'the app\'s internal URL (APP_INTERNAL_URLS), fixed path',