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.

OP OpenVibers committed 27 Sept 2026 · a207b1d28b · 7 files, +258 −8

Export: Media's part goes to Network with a service token: objects.json (the subject's objects with public URLs), vods.json, clips.json and files.json (their rows, stream and storage keys left out).

Deletion: what the subject and its merged-in aliases own goes.
- Native objects are soft-deleted; their bytes are purged after the retention days.
- VODs, clips and files are removed as their delete routes do (bytes everywhere, the row, the object through the row-delete trigger).
- Thumbnails are marked deleted.
- Kept and counted: held media (ADR-006), and paste screenshots and avatars, whose rows sit in the frozen paste tables.
Media then confirms with counts.

test/account-data.test.js covers the export (own objects, no keys), each kind's erasure and the bytes, held and frozen kept, aliases, the confirmation and its retry, and redelivery. The subscription and SSRF tests follow.

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 4ae636d..79490dd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 3f64021..ba9c3c0 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..2843ecc
---- /dev/null
++++ b/server/account-data.js
@@@ -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 };
ddiff --git a/server/revocations.js b/server/revocations.js
iindex 013637b..8c1979e 100644
---- a/server/revocations.js
++++ b/server/revocations.js
@@@ -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 });
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..e66d26a
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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); });
ddiff --git a/test/revocations.test.js b/test/revocations.test.js
iindex 0b5cdf4..2c3ec03 100644
---- a/test/revocations.test.js
++++ b/test/revocations.test.js
@@@ -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 });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 252d3e9..8ee3499 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',