openvibers/openvibe.network Commits
Account export and deletion (roadmap WS-B task 7, ADR-033; openvibe-contracts 0.70.0 -> 0.71.0). server/identity/account-data.js.
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.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","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.70.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.0",-- "integrity": "sha512-NUjKoaP66vsRfyurIXhjzNja/9ELwD8smsMGJ0BuZ0LFpRocTc3yc9rCtHfIztwiuO50sC3E26J3DDlJUvpJyg==",++ "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",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.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","uuid": "^11.1.0",@@@ -412,6 +412,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat<p>Own two accounts? Fold one into the other. Its follows, OpenCoins, linked sign-ins, sessions and settings move to the account you keep, which keeps its name and profile. The other account can't be used on its own afterwards: signing in to it signs you in to the one you kept. If it was a mistake, support can split it by hand within 30 days.</p><div id="merge-body" style="margin-top:12px"></div></div>++ <!-- Your data (ADR-033): a copy of everything, and deleting the account after 30 days. -->++ <div class="card" id="export-card" style="margin-top:16px">++ <h3><span class="icon"><i class="fa-solid fa-file-zipper"></i></span> Download your data</h3>++ <p>Get a copy of what OpenVibe keeps about you: your account here, and your part of Live, Chat, Community, Media and Games, as JSON files in one .zip. Each service adds its own files, so it takes up to half an hour. You can download it for 7 days, and ask for one a day.</p>++ <div id="export-body" style="margin-top:12px"></div>++ </div>++ <div class="card" id="delete-card" style="margin-top:16px">++ <h3><span class="icon"><i class="fa-solid fa-user-xmark"></i></span> Delete your account</h3>++ <p>Your account is deleted 30 days after you ask, and you can cancel any time before then; until that date everything works as usual. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p>++ <div id="delete-body" style="margin-top:12px"></div>++ </div></div><!-- Notifications Section -->@@@ -1212,6 +1223,7 @@ async function loadUser() {loadThemes(); loadThemeSubmissions();loadAccounts();renderMerge();++ renderExport(); renderDeletion();if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');loadNotifPrefs();loadLinked();@@@ -1452,6 +1464,128 @@ function renderMerge() {row.append(doIt, cancel); body.appendChild(row);}++// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and++// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).++async function accountApi(path, opts = {}) {++ const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };++ const t = getAuthToken();++ if (t) headers.Authorization = 'Bearer ' + t;++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });++ let body = {};++ try { body = await res.json(); } catch { /* */ }++ return { status: res.status, ok: res.ok, body };++}++const ovEl = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };++const whenText = (s) => new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });++const sizeText = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);++let exportPoll = null;++async function renderExport() {++ const body = document.getElementById('export-body');++ if (!body || !currentUser) return;++ clearTimeout(exportPoll);++ body.textContent = '';++ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to export.')); return; }++ const r = await accountApi('/api/v1/account/export');++ const latest = ((r.body && r.body.exports) || [])[0];++ const row = ovEl('div', '', 'btn-row');++ if (latest && latest.status === 'pending') {++ const waiting = latest.services.filter((s) => s.status === 'waiting').map((s) => s.service);++ body.appendChild(ovEl('p', `Gathering your data since ${whenText(latest.requested_at)}${waiting.length ? `; waiting for ${waiting.join(', ')}` : ''}. It is ready by ${whenText(latest.deadline)} at the latest, and you get a notification.`));++ exportPoll = setTimeout(renderExport, 15000);++ return;++ }++ if (latest && (latest.status === 'ready' || latest.status === 'partial')) {++ const missing = latest.services.filter((s) => s.status === 'missing').map((s) => s.service);++ body.appendChild(ovEl('p', `Ready since ${whenText(latest.ready_at)} (${sizeText(latest.size_bytes || 0)}), until ${whenText(latest.expires_at)}.${missing.length ? ` Not included, because they did not answer in time: ${missing.join(', ')}. Ask again tomorrow for a complete copy.` : ''}`));++ const dl = ovEl('button', 'Download (.zip)', 'btn btn-primary');++ dl.type = 'button';++ dl.onclick = () => downloadExport(latest.export_id, dl);++ row.appendChild(dl);++ } else if (latest && latest.status === 'failed') {++ body.appendChild(ovEl('p', 'The last export could not be built. Ask again.'));++ }++ const since = latest && latest.status !== 'failed' ? Date.now() - Date.parse(latest.requested_at) : Infinity;++ if (since >= 86400000) {++ const start = ovEl('button', latest ? 'Request a new copy' : 'Request my data', latest ? 'btn btn-outline' : 'btn btn-primary');++ start.type = 'button';++ start.onclick = async () => {++ start.disabled = true;++ const s = await accountApi('/api/v1/account/export', { method: 'POST', body: '{}' });++ if (!s.ok) { start.disabled = false; toast(s.body.detail || 'Could not start the export', 'error'); return; }++ toast('Gathering your data. You get a notification when it is ready.', 'success');++ renderExport();++ };++ row.appendChild(start);++ } else if (latest) {++ body.appendChild(ovEl('p', `You can ask for a new copy from ${whenText(Date.parse(latest.requested_at) + 86400000)}.`));++ }++ if (row.children.length) body.appendChild(row);++}++async function downloadExport(id, btn) {++ btn.disabled = true;++ try {++ const headers = {};++ const t = getAuthToken();++ if (t) headers.Authorization = 'Bearer ' + t;++ const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: 'include' });++ if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }++ const blob = await res.blob();++ const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';++ const a = document.createElement('a');++ a.href = URL.createObjectURL(blob);++ a.download = name;++ document.body.appendChild(a); a.click(); a.remove();++ setTimeout(() => URL.revokeObjectURL(a.href), 60000);++ } catch { toast('The download failed. Try again.', 'error'); } finally { btn.disabled = false; }++}++async function renderDeletion() {++ const body = document.getElementById('delete-body');++ if (!body || !currentUser) return;++ body.textContent = '';++ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to delete.')); return; }++ const r = await accountApi('/api/v1/account/deletion');++ const d = r.body && r.body.deletion;++ const row = ovEl('div', '', 'btn-row');++ if (d) {++ body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));++ const keep = ovEl('button', 'Keep my account', 'btn btn-primary');++ keep.type = 'button';++ keep.onclick = async () => {++ keep.disabled = true;++ const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });++ if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');++ renderDeletion();++ };++ row.appendChild(keep); body.appendChild(row);++ return;++ }++ const field = ovEl('div', '', 'form-field');++ const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);++ const input = ovEl('input');++ input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.id = 'delete-confirm';++ label.htmlFor = 'delete-confirm';++ field.append(label, input);++ const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');++ go.type = 'button';++ go.onclick = async () => {++ go.disabled = true;++ const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });++ go.disabled = false;++ if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {++ toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');++ const again = ovEl('button', 'Sign in again', 'btn btn-outline');++ again.type = 'button'; again.onclick = addAccount;++ if (!row.querySelector('.btn-outline')) row.appendChild(again);++ return;++ }++ if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }++ toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');++ renderDeletion();++ };++ row.appendChild(go);++ body.append(field, row);++}++function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }@@@ -41,6 +41,8 @@ function verifySession(token, { db, publicKey, config }) {if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };++ // A deleted account (ADR-033) is a tombstone: none of its tokens is accepted.++ if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };if (user.token_valid_after) {const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };nnew file mode 100644@@@ -0,0 +1,581 @@++'use strict';++/**++ * Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0).++ *++ * Export. The signed-in person starts a job (POST /api/v1/account/export: one open at a time, one a day). Network++ * queues network.account.export_requested; each service holding network.account.export.contribute pushes its part++ * (POST /internal/account-exports/:id/parts, network.account-export-part@1: JSON files of the subject's own rows).++ * When every expected service has answered, or at the 30-minute deadline, Network adds its own part and writes one++ * zip: README.txt, network/…, <service>/…. It stays downloadable by the same person for 7 days, then it is deleted.++ *++ * Deletion. A fresh sign-in (auth_time within 10 minutes) and the username typed out schedule it 30 days ahead++ * (POST /api/v1/account/deletion); until then the account works and DELETE cancels it. At the date, ONE transaction++ * erases what Network owns:++ * - providers, sessions, OAuth tokens and codes, reset and verification tokens;++ * - modules, follows and blocks both ways, notifications, preferences, push subscriptions, effects;++ * - history, username history, the sign-in IP log and email log;++ * - project memberships; a project the person owns passes to another owner or admin, else it is archived and++ * its apps revoked.++ * The OpenCoins balance is closed with one ledger entry (account_deleted); ledger rows stay. The user row becomes++ * a tombstone (username released, email, password and profile cleared, deleted_at), as do accounts merged into it;++ * the subject is never reused and resolves as deleted. Its tokens are revoked strictly, and network.account.deleted++ * is queued. Each service erases its own rows and confirms (POST /internal/account-deletions/:id/confirmations,++ * network.account-deletion-confirmation@1); staff (staff.users.manage) see what is still outstanding.++ */++const express = require('express');++const fs = require('fs');++const path = require('path');++const { ids, validate, staff } = require('openvibe-contracts');++const eventRelay = require('../developer/event-relay');++const wallet = require('../coins/wallet');++const revocation = require('../auth/revocation');++const zip = require('../utils/zip');++++const EXPORT_DEADLINE_MS = 30 * 60 * 1000;++const EXPORT_KEEP_MS = 7 * 86400000;++const EXPORT_EVERY_MS = 24 * 3600 * 1000;++const PART_MAX_BYTES = 20 * 1024 * 1024;++const GRACE_MS = 30 * 86400000;++const FRESH_SIGN_IN_S = 10 * 60;++const ROW_LIMIT = 5000;++const CONTRIBUTE = 'network.account.export.contribute';++const CONFIRM = 'network.account.deletion.confirm';++const SELF_AUDIENCE = 'openvibe.network';++const ID_RE = { exp: /^exp_[0-9A-HJKMNP-TV-Z]{26}$/, del: /^del_[0-9A-HJKMNP-TV-Z]{26}$/ };++// Columns never exported: secrets, their hashes, one-time codes, push keys.++const SECRET_COL = /(^|_)(token|secret|hash|password|code|p256dh|auth|keys?)($|_)/i;++++class AccountDataError extends Error {++ constructor(status, code, detail, extra) { super(detail); this.status = status; this.code = code; this.extra = extra; }++}++++const iso = (ms) => new Date(ms).toISOString();++++function ensureSchema(db) {++ db.exec(`++ CREATE TABLE IF NOT EXISTS account_exports (++ id TEXT PRIMARY KEY,++ user_id INTEGER NOT NULL,++ subject TEXT NOT NULL,++ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),++ expected TEXT NOT NULL DEFAULT '[]',++ requested_at TEXT NOT NULL,++ deadline TEXT NOT NULL,++ ready_at TEXT,++ expires_at TEXT,++ size_bytes INTEGER,++ services TEXT,++ error TEXT++ );++ CREATE INDEX IF NOT EXISTS idx_account_exports_user ON account_exports(user_id, requested_at);++ CREATE TABLE IF NOT EXISTS account_export_parts (++ export_id TEXT NOT NULL,++ service TEXT NOT NULL,++ received_at TEXT NOT NULL,++ files INTEGER NOT NULL,++ bytes INTEGER NOT NULL,++ body TEXT,++ PRIMARY KEY (export_id, service)++ );++ CREATE TABLE IF NOT EXISTS account_deletions (++ id TEXT PRIMARY KEY,++ user_id INTEGER NOT NULL,++ subject TEXT NOT NULL,++ status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),++ requested_at TEXT NOT NULL,++ delete_after TEXT NOT NULL,++ cancelled_at TEXT,++ cancelled_by TEXT,++ deleted_at TEXT,++ expected TEXT,++ erased TEXT++ );++ CREATE UNIQUE INDEX IF NOT EXISTS idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';++ CREATE TABLE IF NOT EXISTS account_deletion_confirmations (++ deletion_id TEXT NOT NULL,++ service TEXT NOT NULL,++ received_at TEXT NOT NULL,++ completed_at TEXT NOT NULL,++ erased TEXT NOT NULL,++ retained TEXT NOT NULL,++ PRIMARY KEY (deletion_id, service)++ );++ `);++ const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);++ if (!cols.includes('deleted_at')) db.exec('ALTER TABLE users ADD COLUMN deleted_at TEXT');++}++++const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);++const columnsOf = (db, table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);++++/** The services expected to answer: every principal holding the capability for Network. */++function expectedServices(db, capability) {++ if (!tableExists(db, 'principal_grants')) return [];++ return db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL++ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY client_id`).all(capability, SELF_AUDIENCE).map((r) => r.client_id);++}++++function envelope(type, payload, subject, at) {++ const env = {++ event_id: ids.newId('event', Date.parse(at)), event_type: type, version: 1, source: 'network',++ actor: { type: 'user', id: subject }, timestamp: at, visibility: 'internal', subject: { type: 'user', id: subject }, payload,++ };++ const v = validate('events.event-envelope@1', env);++ const pv = validate(`${type}@1`, payload);++ if (!v.valid || !pv.valid) throw new Error(`account-data: bad ${type}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);++ return env;++}++++function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ } }++++function assertPerson(user) {++ if (!user || user.is_anon) throw new AccountDataError(400, 'account.guest', 'a guest account has nothing to export or delete');++ if (user.merged_into) throw new AccountDataError(409, 'account.merged', 'this account was merged into another');++ if (user.deleted_at) throw new AccountDataError(410, 'account.deleted', 'this account was deleted');++ if (!ids.isSubjectId('user', user.subject_id)) throw new AccountDataError(400, 'account.no_subject', 'this account has no subject');++}++++// ── Export ─────────────────────────────────────────────────────++++function exportView(db, row) {++ const parts = db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);++ const got = new Map(parts.map((p) => [p.service, p]));++ const built = row.services ? JSON.parse(row.services) : null;++ const services = built || [...new Set([...JSON.parse(row.expected || '[]'), ...got.keys()])].sort().map((s) => (got.has(s)++ ? { service: s, status: 'received', files: got.get(s).files, bytes: got.get(s).bytes }++ : { service: s, status: row.status === 'pending' ? 'waiting' : 'missing' }));++ const out = { export_id: row.id, status: row.status, requested_at: row.requested_at, deadline: row.deadline, services };++ if (row.ready_at) out.ready_at = row.ready_at;++ if (row.expires_at) out.expires_at = row.expires_at;++ if (row.size_bytes != null) out.size_bytes = row.size_bytes;++ return out;++}++++/** The person starts an export → { status: 201|200, body: network.account-export@1 }. */++function startExport(db, user, { now = Date.now() } = {}) {++ ensureSchema(db);++ assertPerson(user);++ const open = db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);++ if (open) return { status: 200, row: open, body: exportView(db, open) };++ const recent = db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1")++ .get(user.id, iso(now - EXPORT_EVERY_MS));++ if (recent) {++ const retry = Math.ceil((Date.parse(recent.requested_at) + EXPORT_EVERY_MS - now) / 1000);++ throw new AccountDataError(429, 'export.too_soon', 'one export a day: the last one is still available below', { retry_after: retry });++ }++ const id = `exp_${ids.ulid(now)}`;++ const requestedAt = iso(now);++ const deadline = iso(now + EXPORT_DEADLINE_MS);++ const expected = expectedServices(db, CONTRIBUTE);++ db.transaction(() => {++ db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)')++ .run(id, user.id, user.subject_id, JSON.stringify(expected), requestedAt, deadline);++ eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));++ })();++ kick(db);++ const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);++ return { status: 201, row, body: exportView(db, row) };++}++++/** A service's part → network.account-data-receipt@1. */++function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {++ ensureSchema(db);++ const v = validate('network.account-export-part@1', body);++ if (!v.valid) throw new AccountDataError(400, 'export.invalid_part', JSON.stringify(v.errors).slice(0, 300));++ const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;++ if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');++ if (body.subject !== row.subject) throw new AccountDataError(400, 'export.wrong_subject', "the part's subject is not the export's");++ if (row.status !== 'pending' || now > Date.parse(row.deadline)) throw new AccountDataError(409, 'export.closed', 'this export was already built');++ const names = body.files.map((f) => f.name);++ if (new Set(names).size !== names.length) throw new AccountDataError(400, 'export.duplicate_file', 'two files with the same name');++ const text = JSON.stringify(body);++ const bytes = Buffer.byteLength(text);++ if (bytes > PART_MAX_BYTES) throw new AccountDataError(413, 'export.part_too_large', 'a part is at most 20 MB');++ const had = !!db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);++ db.prepare('INSERT OR REPLACE INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?)')++ .run(row.id, service, iso(now), body.files.length, bytes, text);++ return { id: row.id, service, received_at: iso(now), replaced: had };++}++++/** Rows about a person from one table, secrets left out, newest first, at most ROW_LIMIT. */++function rowsOf(db, table, where, params, { omit = [] } = {}) {++ if (!tableExists(db, table)) return { rows: [], truncated: false };++ const cols = columnsOf(db, table).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));++ if (!cols.length) return { rows: [], truncated: false };++ const rows = db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...params);++ return { rows: rows.slice(0, ROW_LIMIT), truncated: rows.length > ROW_LIMIT };++}++++/** Network's own part: { files: [{ name, content }], truncated: [] }. */++function networkPart(db, userId) {++ const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);++ const files = [];++ const truncated = [];++ const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };++ const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',++ 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];++ const account = {};++ for (const k of keep) if (k in u) account[k] = u[k];++ account.merged_in = tableExists(db, 'subject_aliases')++ ? db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];++ add('account.json', account);++ const byUser = (name, table, opts) => { const r = rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };++ byUser('sign_in_providers.json', 'linked_accounts');++ byUser('sessions.json', 'user_sessions');++ byUser('oauth_grants.json', 'oauth_tokens');++ byUser('notifications.json', 'notifications');++ add('preferences.json', {++ display: rowsOf(db, 'user_preferences', 'user_id = ?', [u.id]).rows[0] || null,++ notifications: rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id]).rows,++ effects: rowsOf(db, 'user_effects', 'user_id = ?', [u.id]).rows,++ push_devices: rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] }).rows,++ });++ byUser('history.json', 'user_history');++ add('username_history.json', rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] }).rows);++ byUser('profile_changes.json', 'user_profile_changes');++ byUser('sign_in_log.json', 'ip_log');++ byUser('emails_sent.json', 'email_delivery_log');++ const coins = rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);++ add('opencoins.json', { balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);++ add('modules.json', tableExists(db, 'user_modules') ? db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id)++ .map((m) => { let data = m.data; try { data = JSON.parse(m.data); } catch { /* kept as text */ } return { ...m, data }; }) : []);++ const follows = rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);++ const followers = tableExists(db, 'user_follows') ? db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id).n : 0;++ add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);++ add('blocks.json', rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id]).rows);++ add('developer_projects.json', {++ owned: rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id]).rows,++ memberships: rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] }).rows,++ });++ add('account_merges.json', tableExists(db, 'account_merges')++ ? db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);++ add('moderation.json', rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] }).rows);++ return { files, truncated };++}++++function readme(row, sections, now) {++ const lines = [++ 'OpenVibe account export',++ `Export ${row.id} for ${row.subject}, requested ${row.requested_at}, built ${iso(now)}.`,++ '',++ 'Each folder is one service\'s part: the rows it keeps about you, as JSON. Secrets (passwords, tokens and their',++ 'hashes) are never included. Media files themselves are downloaded from the links in the media part.',++ '',++ ];++ for (const s of sections) {++ if (s.status !== 'received') { lines.push(`${s.service}: MISSING (it did not answer before ${row.deadline}; ask for a new export later)`); continue; }++ lines.push(`${s.service}: ${s.files} file(s)${s.truncated.length ? `; cut at the service's row limit: ${s.truncated.join(', ')}` : ''}${s.note ? ` (${s.note})` : ''}`);++ }++ return lines.join('\n') + '\n';++}++++/** Build the archive when every expected service answered, at the deadline, or when forced. → true if built. */++function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {++ ensureSchema(db);++ const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);++ if (!row || row.status !== 'pending') return false;++ const expected = JSON.parse(row.expected || '[]');++ const parts = db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);++ const got = new Set(parts.map((p) => p.service));++ if (!force && now < Date.parse(row.deadline) && !expected.every((s) => got.has(s))) return false;++ try {++ const own = networkPart(db, row.user_id);++ const sections = [{ service: 'network', status: 'received', files: own.files.length, truncated: own.truncated }];++ const entries = own.files.map((f) => ({ name: `network/${f.name}`, data: JSON.stringify(f.content, null, 2) }));++ for (const p of parts) {++ const b = JSON.parse(p.body || '{"files":[]}');++ sections.push({ service: p.service, status: 'received', files: b.files.length, truncated: b.truncated || [], note: b.note });++ for (const f of b.files) entries.push({ name: `${p.service}/${f.name}`, data: JSON.stringify(f.content, null, 2) });++ }++ for (const s of expected) if (!got.has(s)) sections.push({ service: s, status: 'missing' });++ entries.unshift({ name: 'README.txt', data: readme(row, sections, now) });++ const buf = zip.build(entries, { date: new Date(now) });++ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });++ const file = path.join(dir, `${row.id}.zip`);++ fs.writeFileSync(file, buf, { mode: 0o600 });++ const status = expected.every((s) => got.has(s)) ? 'ready' : 'partial';++ const services = sections.map((s) => (s.status === 'received'++ ? { service: s.service, status: 'received', files: s.files, bytes: s.service === 'network' ? Buffer.byteLength(JSON.stringify(own.files)) : parts.find((p) => p.service === s.service).bytes }++ : { service: s.service, status: 'missing' }));++ db.transaction(() => {++ db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?')++ .run(status, iso(now), iso(now + EXPORT_KEEP_MS), buf.length, JSON.stringify(services), row.id);++ db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);++ })();++ if (notify) {++ try {++ notify(row.user_id, { title: 'Your data export is ready', message: status === 'ready' ? 'Download it from your account page within 7 days.' : 'Some services did not answer in time; the archive says which. Download it from your account page within 7 days.' });++ } catch { /* the page shows it anyway */ }++ }++ return true;++ } catch (e) {++ db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);++ console.error('[AccountData] export build failed:', row.id, e.message);++ return false;++ }++}++++function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {++ ensureSchema(db);++ const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;++ if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');++ if (row.status === 'pending') throw new AccountDataError(409, 'export.pending', 'this export is still being gathered');++ if (!['ready', 'partial'].includes(row.status) || Date.parse(row.expires_at) < now) throw new AccountDataError(410, 'export.expired', 'this export is no longer available');++ return { row, file: path.join(dir, `${row.id}.zip`) };++}++++// ── Deletion ───────────────────────────────────────────────────++++function deletionView(row) {++ const out = { deletion_id: row.id, status: row.status, requested_at: row.requested_at, delete_after: row.delete_after };++ if (row.cancelled_at) out.cancelled_at = row.cancelled_at;++ if (row.deleted_at) out.deleted_at = row.deleted_at;++ return out;++}++++function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {++ ensureSchema(db);++ assertPerson(user);++ const open = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);++ if (open) return { status: 200, body: deletionView(open) };++ const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();++ if (String(user.username || '').toLowerCase() === owner) throw new AccountDataError(403, 'deletion.owner', "the owner's account cannot be deleted here");++ const authTime = Number(claims && claims.auth_time);++ if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {++ throw new AccountDataError(401, 'deletion.sign_in_again', 'sign in again (within the last 10 minutes) to delete this account');++ }++ if (String(confirmUsername || '').trim().toLowerCase() !== String(user.username).toLowerCase()) {++ throw new AccountDataError(400, 'deletion.confirm_username', 'type your username exactly to confirm');++ }++ const id = `del_${ids.ulid(now)}`;++ db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')++ .run(id, user.id, user.subject_id, iso(now), iso(now + GRACE_MS));++ return { status: 201, body: deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };++}++++function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {++ ensureSchema(db);++ const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);++ if (!row) return null;++ db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);++ return deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));++}++++/** Erase what Network owns for one due deletion, in one transaction, and queue network.account.deleted. → counts. */++function erase(db, deletion, { now = Date.now() } = {}) {++ const at = iso(now);++ const erased = {};++ const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };++ const run = db.transaction(() => {++ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);++ if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);++ const subject = user.subject_id;++ const folded = db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);++ const aliases = tableExists(db, 'subject_aliases') ? db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject).map((r) => r.alias_id) : [];++ const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];++ const userIds = [user.id, ...folded.map((f) => f.id)];++ const inU = `(${userIds.map(() => '?').join(',')})`;++ const inS = `(${subjects.map(() => '?').join(',')})`;++ const del = (table, where, params, key = table) => { if (tableExists(db, table)) count(key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes); };++++ for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',++ 'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',++ 'ip_log', 'email_delivery_log']) del(t, `user_id IN ${inU}`, userIds);++ if (tableExists(db, 'account_merge_intents')) del('account_merge_intents', `into_user_id IN ${inU}`, userIds);++ // Notifications this person sent others stay, without their name or picture.++ if (tableExists(db, 'notifications')) db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);++ if (tableExists(db, 'analytics_events')) db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);++ del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');++ del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');++ del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');++ if (tableExists(db, 'dev_projects')) {++ for (const p of db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {++ const heir = db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')++ ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, added_at LIMIT 1`).get(p.id, ...subjects);++ if (heir) { db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }++ for (const a of db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {++ db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);++ db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);++ }++ db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);++ count('projects_archived', 1);++ }++ del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');++ }++ if (tableExists(db, 'account_merges')) db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);++ // OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.++ for (const id of userIds) {++ const bal = (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;++ if (bal > 0) {++ wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });++ count('opencoins_closed', bal);++ }++ }++ // The tombstones: the username is released, the rest cleared; the subject stays (never reused).++ const cols = columnsOf(db, 'users');++ for (const id of userIds) {++ const u = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);++ const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;++ const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,++ name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };++ const keys = Object.keys(set).filter((k) => cols.includes(k));++ db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);++ count('accounts', 1);++ }++ revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });++ const expected = expectedServices(db, CONFIRM);++ db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?")++ .run(at, JSON.stringify(expected), JSON.stringify(erased), deletion.id);++ const payload = { deletion_id: deletion.id, subject, requested_at: deletion.requested_at, deleted_at: at };++ const others = subjects.filter((s) => s !== subject).slice(0, 50);++ if (others.length) payload.aliases = others;++ eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));++ });++ run();++ kick(db);++ return erased;++}++++/** A service confirms a deletion → network.account-data-receipt@1. */++function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {++ ensureSchema(db);++ const v = validate('network.account-deletion-confirmation@1', body);++ if (!v.valid) throw new AccountDataError(400, 'deletion.invalid_confirmation', JSON.stringify(v.errors).slice(0, 300));++ const row = ID_RE.del.test(String(deletionId)) ? db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;++ if (!row) throw new AccountDataError(404, 'deletion.not_found', 'no such deletion');++ if (body.subject !== row.subject) throw new AccountDataError(400, 'deletion.wrong_subject', "the confirmation's subject is not the deletion's");++ if (row.status !== 'deleted') throw new AccountDataError(409, 'deletion.not_deleted', 'this deletion has not happened');++ const had = !!db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);++ db.prepare('INSERT OR REPLACE INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?)')++ .run(row.id, service, iso(now), body.completed_at, JSON.stringify(body.erased), JSON.stringify(body.retained || {}));++ return { id: row.id, service, received_at: iso(now), replaced: had };++}++++/** The periodic sweep: build exports past their deadline, delete expired archives, carry out due deletions. */++function sweep(db, { dir, now = Date.now(), notify } = {}) {++ ensureSchema(db);++ const out = { built: 0, expired: 0, deleted: 0 };++ for (const r of db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {++ if (maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;++ }++ for (const r of db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {++ try { fs.rmSync(path.join(dir, `${r.id}.zip`), { force: true }); } catch { /* gone already */ }++ db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);++ out.expired++;++ }++ for (const d of db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {++ try { const e = erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }++ catch (e) { console.error('[AccountData] deletion failed:', d.id, e.message); }++ }++ return out;++}++++function sendError(res, e) {++ if (e instanceof AccountDataError) {++ if (e.extra && e.extra.retry_after) res.set('Retry-After', String(e.extra.retry_after));++ return res.status(e.status).json({ error: e.code, detail: e.message, ...(e.extra || {}) });++ }++ console.error('[AccountData]', e);++ return res.status(500).json({ error: 'account_data.failed', detail: 'that did not work' });++}++++/**++ * Routers: `me` under /api/v1/account (the person), `internal` under /internal (service tokens: contributeGuard,++ * confirmGuard) and `admin` under /api/admin/account-deletions (staff.users.manage).++ */++function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir, notify }) {++ const me = express.Router();++ me.use(express.json({ limit: '8kb' }));++ const noStore = (res) => res.set('Cache-Control', 'private, no-store');++ me.post('/export', requireAuth, (req, res) => {++ const db = req.app.locals.db;++ try {++ const out = startExport(db, req.user);++ if (out.status === 201) setImmediate(() => maybeBuild(db, out.row.id, { dir, notify }));++ noStore(res).status(out.status).json(out.body);++ } catch (e) { sendError(res, e); }++ });++ me.get('/export', requireAuth, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const rows = db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);++ noStore(res).json({ exports: rows.map((r) => exportView(db, r)) });++ });++ me.get('/export/:id', requireAuth, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const row = ID_RE.exp.test(req.params.id) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;++ if (!row) return res.status(404).json({ error: 'export.not_found' });++ noStore(res).json(exportView(db, row));++ });++ me.get('/export/:id/download', requireAuth, (req, res) => {++ try {++ const { row, file } = exportFile(req.app.locals.db, req.user, req.params.id, { dir });++ if (!fs.existsSync(file)) return res.status(410).json({ error: 'export.expired' });++ const day = row.ready_at.slice(0, 10);++ noStore(res).set('Content-Type', 'application/zip')++ .set('Content-Disposition', `attachment; filename="openvibe-${String(req.user.username).replace(/[^A-Za-z0-9_.-]/g, '_')}-${day}.zip"`)++ .set('X-Content-Type-Options', 'nosniff');++ fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);++ } catch (e) { sendError(res, e); }++ });++ me.get('/deletion', requireAuth, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);++ noStore(res).json({ deletion: row ? deletionView(row) : null });++ });++ me.post('/deletion', requireAuth, (req, res) => {++ try {++ const out = scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);++ noStore(res).status(out.status).json(out.body);++ } catch (e) { sendError(res, e); }++ });++ me.delete('/deletion', requireAuth, (req, res) => {++ const out = cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });++ if (!out) return res.status(404).json({ error: 'deletion.not_scheduled' });++ noStore(res).json(out);++ });++++ const internal = express.Router();++ const service = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');++ internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), (req, res) => {++ const db = req.app.locals.db;++ try {++ const receipt = receivePart(db, req.params.id, service(req), req.body);++ setImmediate(() => maybeBuild(db, receipt.id, { dir, notify }));++ res.json(receipt);++ } catch (e) { sendError(res, e); }++ });++ internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), (req, res) => {++ try { res.json(receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }++ });++++ const admin = express.Router();++ admin.use(express.json({ limit: '8kb' }));++ const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.users.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.users.manage required' }));++ admin.get('/', requireAuth, staffOnly, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const rows = db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));++ const confirmed = db.prepare('SELECT service FROM account_deletion_confirmations WHERE deletion_id = ?');++ noStore(res).json({++ deletions: rows.map((r) => {++ const got = confirmed.all(r.id).map((c) => c.service);++ return { ...deletionView(r), subject: r.subject, confirmed: got, outstanding: JSON.parse(r.expected || '[]').filter((s) => !got.includes(s)) };++ }),++ });++ });++ admin.post('/:id/cancel', requireAuth, staffOnly, (req, res) => {++ const db = req.app.locals.db; ensureSchema(db);++ const reason = String((req.body && req.body.reason) || '').trim();++ if (reason.length < 10) return res.status(400).json({ error: 'deletion.reason_required', detail: 'a written reason (10 characters or more)' });++ const row = ID_RE.del.test(req.params.id) ? db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;++ if (!row) return res.status(404).json({ error: 'deletion.not_scheduled' });++ const out = cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));++ res.json(out);++ });++ return { me, internal, admin };++}++++module.exports = {++ ensureSchema, startExport, receivePart, maybeBuild, networkPart, exportFile, exportView, scheduleDeletion, cancelDeletion, erase,++ receiveConfirmation, sweep, routers, expectedServices, AccountDataError, GRACE_MS, EXPORT_DEADLINE_MS, EXPORT_KEEP_MS,++};@@@ -55,6 +55,12 @@ const DEFAULT_GRANTS = [// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).['media', 'events.subscription.manage', 'openvibe.events', []],['tools', 'events.subscription.manage', 'openvibe.events', []],++ // Account export and deletion (ADR-033): the services that keep data about people push their export part and++ // confirm a deletion; the holders of each grant are the services Network waits for.++ ...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [++ [svc, 'network.account.export.contribute', SELF_AUDIENCE, []],++ [svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],++ ]),// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and// announces delivered tips in the creator's Live chat.['tips', 'billing.intent.create', 'openvibe.billing', []],@@@ -109,8 +109,10 @@ const newGuestSubjectId = () => ids.newId('guest');function projection(db, subjectId) {if (typeof subjectId !== 'string') return null;if (subjectId.startsWith('usr_')) {-- const u = db.prepare('SELECT id, subject_id, username, display_name, avatar_url, is_banned FROM users WHERE subject_id = ?').get(subjectId);++ const u = db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);if (!u) return null;++ // A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.++ if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };}@@@ -161,7 +161,9 @@ app.use('/api/webhooks', require('./notifications/resend-webhook')());// service exist (below); inert (503) until NETWORK_EVENTS_SECRET is set.let eventsConsumer = null;app.use('/internal/events', (req, res, next) => (eventsConsumer ? eventsConsumer.router(req, res, next) : res.status(503).json({ error: 'starting' })));--app.use(express.json({ limit: '1mb' }));++// Export parts (ADR-033) carry up to 20 MB and are parsed by their own route.++const jsonBody = express.json({ limit: '1mb' });++app.use((req, res, next) => (req.method === 'POST' && /^\/internal\/account-exports\/[^/]+\/parts$/.test(req.path) ? next() : jsonBody(req, res, next)));app.use(express.urlencoded({ extended: true }));// ── CORS ─────────────────────────────────────────────────────@@@ -417,6 +419,23 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);app.use('/api/admin/account-merges', mergeRouters.admin);}++// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;++// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is++// outstanding. Archives live next to the database for 7 days.++const ACCOUNT_EXPORT_DIR = path.join(path.dirname(path.resolve(config.db.path)), 'account-exports');++const notifyAccountData = (userId, n) => notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });++{++ const accountData = require('./identity/account-data');++ accountData.ensureSchema(db);++ const guard = require('./identity/principals').guard;++ const dataRouters = accountData.routers({++ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,++ contributeGuard: guard('network.account.export.contribute', { legacy: false }), confirmGuard: guard('network.account.deletion.confirm', { legacy: false }),++ });++ app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);++ app.use('/internal', dataRouters.internal);++ app.use('/api/admin/account-deletions', dataRouters.admin);++}app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).@@@ -837,6 +856,14 @@ const server = app.listen(config.port, config.host, () => {// Account merges older than 30 days keep only the alias facts (ADR-029).const reduceMerges = () => { try { const n = require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));++ // Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).++ const sweepAccountData = () => {++ try {++ const n = require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });++ if (n.built || n.expired || n.deleted) console.log(`[AccountData] sweep: ${JSON.stringify(n)}`);++ } catch (e) { console.warn('[AccountData] sweep failed:', e.message); }++ };++ timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));// Clean expired sessions dailytimers.push(setInterval(() => {nnew file mode 100644@@@ -0,0 +1,90 @@++'use strict';++/**++ * A minimal zip archive (deflate, UTF-8 names, no zip64) for the account export (ADR-033): a handful of JSON files,++ * well under 4 GB. build(entries) → Buffer; read(buffer) → { name: Buffer } (tests, and a check after writing).++ */++const zlib = require('zlib');++++function dos(d) {++ return {++ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2),++ date: ((Math.max(1980, d.getUTCFullYear()) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),++ };++}++++/** entries: [{ name, data: Buffer|string }] */++function build(entries, { date = new Date() } = {}) {++ const { time, date: day } = dos(date);++ const locals = [];++ const central = [];++ let offset = 0;++ for (const e of entries) {++ const name = Buffer.from(e.name, 'utf8');++ const raw = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');++ const crc = zlib.crc32(raw);++ const body = zlib.deflateRawSync(raw);++ const h = Buffer.alloc(30);++ h.writeUInt32LE(0x04034b50, 0);++ h.writeUInt16LE(20, 4);++ h.writeUInt16LE(0x0800, 6); // UTF-8 names++ h.writeUInt16LE(8, 8); // deflate++ h.writeUInt16LE(time, 10);++ h.writeUInt16LE(day, 12);++ h.writeUInt32LE(crc, 14);++ h.writeUInt32LE(body.length, 18);++ h.writeUInt32LE(raw.length, 22);++ h.writeUInt16LE(name.length, 26);++ locals.push(h, name, body);++ const c = Buffer.alloc(46);++ c.writeUInt32LE(0x02014b50, 0);++ c.writeUInt16LE(20, 4);++ c.writeUInt16LE(20, 6);++ c.writeUInt16LE(0x0800, 8);++ c.writeUInt16LE(8, 10);++ c.writeUInt16LE(time, 12);++ c.writeUInt16LE(day, 14);++ c.writeUInt32LE(crc, 16);++ c.writeUInt32LE(body.length, 20);++ c.writeUInt32LE(raw.length, 24);++ c.writeUInt16LE(name.length, 28);++ c.writeUInt32LE(offset, 42);++ central.push(c, name);++ offset += 30 + name.length + body.length;++ }++ const dir = Buffer.concat(central);++ const end = Buffer.alloc(22);++ end.writeUInt32LE(0x06054b50, 0);++ end.writeUInt16LE(entries.length, 8);++ end.writeUInt16LE(entries.length, 10);++ end.writeUInt32LE(dir.length, 12);++ end.writeUInt32LE(offset, 16);++ return Buffer.concat([...locals, dir, end]);++}++++function read(buf) {++ const endAt = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));++ if (endAt < 0) throw new Error('zip: no end record');++ const count = buf.readUInt16LE(endAt + 10);++ let p = buf.readUInt32LE(endAt + 16);++ const out = {};++ for (let i = 0; i < count; i++) {++ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('zip: bad central entry');++ const method = buf.readUInt16LE(p + 10);++ const crc = buf.readUInt32LE(p + 16);++ const size = buf.readUInt32LE(p + 20);++ const nameLen = buf.readUInt16LE(p + 28);++ const extra = buf.readUInt16LE(p + 30);++ const comment = buf.readUInt16LE(p + 32);++ const local = buf.readUInt32LE(p + 42);++ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);++ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);++ const body = buf.subarray(start, start + size);++ const data = method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body);++ if (zlib.crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);++ out[name] = data;++ p += 46 + nameLen + extra + comment;++ }++ return out;++}++++module.exports = { build, read };nnew file mode 100644@@@ -0,0 +1,255 @@++'use strict';++// Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). The ADR's acceptance tests:++// - export: a job gathers every expected service's part (the holders of network.account.export.contribute), the zip++// holds only the person's data and no secrets, a missing service makes it partial and says which, another person++// cannot download it, one a day, and it expires after 7 days;++// - deletion: refused without a fresh sign-in or the typed username, cancellable, nothing before the date;++// - at the date Network's rows are erased, the username is released, the subject resolves as deleted, old tokens++// are refused, and network.account.deleted (with the merged-in aliases) is queued once;++// - services confirm with counts; staff (staff.users.manage) see what is outstanding and cancel with a reason.++// node test/account-data.test.js++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const cookieParser = require('cookie-parser');++const jwt = require('jsonwebtoken');++const bcrypt = require('bcryptjs');++const { validate, serviceAuth } = require('openvibe-contracts');++const { initDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const wallet = require('../server/coins/wallet');++const accountMerge = require('../server/identity/account-merge');++const accountData = require('../server/identity/account-data');++const zip = require('../server/utils/zip');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-account-data-'));++const exportDir = path.join(dir, 'account-exports');++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++// The five services that keep data about people are registered clients (production has them all).++for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);++const principals = require('../server/identity/principals');++principals.ensureSchema(db);++require('../server/identity/follows').ensureSchema(db);++accountMerge.ensureSchema(db);++accountData.ensureSchema(db);++const ISSUER = 'https://openvibe.network';++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });++const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };++process.env.OWNER_USERNAME = 'boss';++++const hash = bcrypt.hashSync('secret12', 4);++const mk = (name, role = 'user') => {++ const id = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(name, `${name}@example.com`, hash, role).lastInsertRowid;++ subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));++ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);++};++const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',++ ...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++// A service token as Network issues it: `cap` holds only what principal_grants gives that service.++const svc = (name, cap = ['network.account.export.contribute', 'network.account.deletion.confirm']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'],++ cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);++const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);++++const authRoutes = require('../server/auth/routes');++const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);++const notified = [];++const app = express();++Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++app.use(cookieParser());++const routers = accountData.routers({++ requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims, dir: exportDir, notify: (u, n) => notified.push([u, n.title]),++ contributeGuard: principals.guard('network.account.export.contribute', { legacy: false }), confirmGuard: principals.guard('network.account.deletion.confirm', { legacy: false }),++});++app.use('/api/v1/account', routers.me);++app.use('/internal', routers.internal);++app.use('/api/admin/account-deletions', routers.admin);++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 call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, headers: r.headers, buf: Buffer.from(await r.arrayBuffer()) }))++ .then((r) => { try { r.body = JSON.parse(r.buf.toString('utf8')); } catch { r.body = {}; } return r; });++ const settle = () => new Promise((r) => setImmediate(r));++ try {++ const dana = mk('dana'); const eve = mk('eve'); const x = mk('xavier'); const boss = mk('boss', 'admin');++ db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);++ db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);++ db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);++ wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });++ require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });++ const follows = require('../server/identity/follows');++ follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });++ follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });++ db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);++++ // ── Export ──++ const expected = accountData.expectedServices(db, 'network.account.export.contribute');++ assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');++ let r = await call('POST', '/api/v1/account/export', tok(dana));++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));++ const exp = r.body.export_id;++ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), expected.map((s) => `${s}:waiting`));++ const req = events('network.account.export_requested');++ assert.strictEqual(req.length, 1);++ assert.deepStrictEqual([req[0].payload.export_id, req[0].payload.subject, req[0].visibility], [exp, dana.subject_id, 'internal']);++ r = await call('POST', '/api/v1/account/export', tok(dana));++ assert.deepStrictEqual([r.status, r.body.export_id], [200, exp], 'one open export at a time');++++ const part = (files, subject = dana.subject_id) => ({ subject, files });++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('live'), part([{ name: 'follows.json', content: [{ channel: 'xavier' }] }, { name: 'profile.json', content: { bio: 'hi' } }]));++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.ok(validate('network.account-data-receipt@1', r.body).valid);++ assert.deepStrictEqual([r.body.service, r.body.replaced], ['live', false]);++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('live'), part([{ name: 'follows.json', content: [{ channel: 'xavier' }, { channel: 'yolanda' }] }]));++ assert.strictEqual(r.body.replaced, true, 'a second part replaces the first');++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));++ assert.deepStrictEqual([r.status, r.body.error], [400, 'export.wrong_subject']);++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: '../network/account.json', content: {} }]));++ assert.deepStrictEqual([r.status, r.body.error], [400, 'export.invalid_part']);++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('tools', ['network.modules.read']), part([{ name: 'usage.json', content: [] }]));++ assert.strictEqual(r.status, 403, 'a service without the grant is refused');++ r = await call('POST', `/internal/account-exports/${exp}/parts`, tok(dana), part([{ name: 'x.json', content: [] }]));++ assert.strictEqual(r.status, 401, 'a person token is not a service token');++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [{ message: 'hello' }] }]));++ assert.strictEqual(r.status, 200);++ await settle();++ assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp).status, 'pending', 'still waiting for three services');++ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));++ assert.deepStrictEqual([r.status, r.body.error], [409, 'export.pending']);++++ // The deadline passes: the archive is built without the silent services, and says so.++ const deadline = Date.parse(db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp).deadline);++ assert.deepStrictEqual(accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });++ r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));++ assert.strictEqual(r.body.status, 'partial');++ assert.ok(validate('network.account-export@1', r.body).valid);++ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing']);++ assert.deepStrictEqual(notified.map((n) => n[0]), [dana.id], 'the person is told');++ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('content-type'), 'application/zip');++ assert.match(r.headers.get('content-disposition'), /attachment; filename="openvibe-dana-\d{4}-\d\d-\d\d\.zip"/);++ const files = zip.read(r.buf);++ assert.ok(files['README.txt'].toString().includes('community: MISSING'));++ assert.deepStrictEqual(JSON.parse(files['live/follows.json']).map((f) => f.channel), ['xavier', 'yolanda'], 'the replacing part');++ const account = JSON.parse(files['network/account.json']);++ assert.deepStrictEqual([account.username, account.email, account.subject_id], ['dana', '[email protected]', dana.subject_id]);++ const all = Object.values(files).map((b) => b.toString()).join('\n');++ for (const secret of ['password_hash', 'sess-secret-dana', 'rt-secret-dana', hash]) assert.ok(!all.includes(secret), `no ${secret} in the archive`);++ assert.strictEqual(JSON.parse(files['network/opencoins.json']).balance, 40);++ assert.deepStrictEqual(JSON.parse(files['network/follows.json']).followers_count, 1, 'followers as a count, never their names');++ assert.ok(!all.includes('[email protected]'), "nobody else's data");++ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(eve));++ assert.strictEqual(r.status, 404, 'another person cannot download it');++ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('media'), part([{ name: 'objects.json', content: [] }]));++ assert.deepStrictEqual([r.status, r.body.error], [409, 'export.closed'], 'too late');++ r = await call('POST', '/api/v1/account/export', tok(dana));++ assert.deepStrictEqual([r.status, r.body.error], [429, 'export.too_soon'], 'one a day');++ assert.ok(Number(r.headers.get('retry-after')) > 0);++++ // Every expected service answers: ready at once.++ for (const s of ['community', 'games', 'media']) db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);++ r = await call('POST', '/api/v1/account/export', tok(eve));++ const exp2 = r.body.export_id;++ await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));++ await call('POST', `/internal/account-exports/${exp2}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));++ await settle(); await settle();++ assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2).status, 'ready');++++ // After 7 days the archive is gone.++ const expires = Date.parse(db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp).expires_at);++ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: expires + 1000 }).expired, 2);++ assert.ok(!fs.existsSync(path.join(exportDir, `${exp}.zip`)));++ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));++ assert.deepStrictEqual([r.status, r.body.error], [410, 'export.expired']);++++ // ── Deletion ──++ r = await call('POST', '/api/v1/account/deletion', tok(dana, { authAgo: 11 * 60 }), { confirm_username: 'dana' });++ assert.deepStrictEqual([r.status, r.body.error], [401, 'deletion.sign_in_again']);++ r = await call('POST', '/api/v1/account/deletion', tok(dana, { authAgo: null }), { confirm_username: 'dana' });++ assert.deepStrictEqual([r.status, r.body.error], [401, 'deletion.sign_in_again'], 'a renewal is not a sign-in');++ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dan' });++ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.confirm_username']);++ r = await call('POST', '/api/v1/account/deletion', tok(boss), { confirm_username: 'boss' });++ assert.deepStrictEqual([r.status, r.body.error], [403, 'deletion.owner']);++ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'DANA' });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.ok(validate('network.account-deletion@1', r.body).valid);++ assert.strictEqual(Date.parse(r.body.delete_after) - Date.parse(r.body.requested_at), 30 * 86400000);++ r = await call('DELETE', '/api/v1/account/deletion', tok(dana));++ assert.strictEqual(r.body.status, 'cancelled', 'cancelled');++ r = await call('GET', '/api/v1/account/deletion', tok(dana));++ assert.strictEqual(r.body.deletion, null);++ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });++ const del = r.body.deletion_id;++ const due = Date.parse(r.body.delete_after);++ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due - 60000 }).deleted, 0, 'nothing before the date');++ assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id).username, 'dana');++++ // dana had folded an older account in (ADR-029): its subject goes with her.++ const old = mk('dana_old');++ accountMerge.merge(db, old, db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });++ const oldToken = tok(dana);++ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 1000 }).deleted, 1);++ const gone = db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);++ assert.match(gone.username, /^deleted-/);++ assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);++ assert.match(db.prepare('SELECT username FROM users WHERE id = ?').get(old.id).username, /^deleted-/, 'the merged-in account too');++ for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {++ assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id).n, 0, `${t} erased`);++ }++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id).n, 0);++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id).n, 0, 'follows both ways');++ assert.strictEqual(wallet.getBalance ? wallet.getBalance(db, dana.id) : db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id).balance, 0);++ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id).n, 1, 'one closing entry; the ledger stays');++ assert.ok(mk('dana'), 'the username is released');++ const p = subjects.resolve(db, { subject_id: dana.subject_id });++ assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);++ assert.strictEqual(subjects.resolve(db, { subject_id: old.subject_id }).deleted, true, 'the alias resolves as deleted too');++ r = await call('GET', '/api/v1/account/deletion', oldToken);++ assert.strictEqual(r.status, 401, 'old tokens are refused');++ const ev = events('network.account.deleted');++ assert.strictEqual(ev.length, 1);++ assert.ok(validate('network.account.deleted@1', ev[0].payload).valid);++ assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);++ assert.ok(events('network.user.token_valid_after').some((e) => e.payload.reason === 'account_deleted'));++ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 5000 }).deleted, 0, 'once');++++ // Services confirm; staff see what is outstanding.++ const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };++ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('chat'), confirm);++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual([r.body.id, r.body.service, r.body.replaced], [del, 'chat', false]);++ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('chat'), confirm);++ assert.strictEqual(r.body.replaced, true);++ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('live'), { ...confirm, subject: eve.subject_id });++ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.wrong_subject']);++ r = await call('GET', '/api/admin/account-deletions', tok(eve));++ assert.strictEqual(r.status, 403);++ r = await call('GET', '/api/admin/account-deletions', tok(boss));++ const row = r.body.deletions.find((d) => d.deletion_id === del);++ assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media']]);++++ // Staff cancel a scheduled deletion only with a reason.++ await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });++ const eveDel = db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id).id;++ r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'no' });++ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.reason_required']);++ r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'the person wrote to support and changed their mind' });++ assert.strictEqual(r.body.status, 'cancelled');++ assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('account export and deletion: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -53,7 +53,7 @@ const server = http.createServer(app);const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });assert.ok(claims.ok, claims.reason);assert.strictEqual(claims.claims.sub, 'svc:live');-- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);++ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');const full = t.body.access_token;@@@ -90,7 +90,7 @@ const server = http.createServer(app);assert.deepStrictEqual(holders, ['community', 'media', 'openre']);t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });assert.strictEqual(t.status, 200, JSON.stringify(t.body));-- assert.strictEqual(t.body.scope, 'identity.subject.resolve', 'Media resolves object owners to subjects, and nothing else here');++ assert.strictEqual(t.body.scope, 'identity.subject.resolve network.account.deletion.confirm network.account.export.contribute', 'Media resolves object owners to subjects and takes part in account export and deletion (ADR-033), nothing else here');const mediaResolve = t.body.access_token;t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });assert.strictEqual(t.status, 400, 'no grants for that audience');@@@ -129,7 +129,7 @@ const server = http.createServer(app);db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });assert.strictEqual(com.status, 200, JSON.stringify(com.body));-- assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, platform blocks, plus its community.profile module');++ assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.70.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.70.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.70.0",
- "integrity": "sha512-NUjKoaP66vsRfyurIXhjzNja/9ELwD8smsMGJ0BuZ0LFpRocTc3yc9rCtHfIztwiuO50sC3E26J3DDlJUvpJyg==",
+ "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",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/account-merge.test.js && node test/account-data.test.js && node test/status-incidents.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/developer-export-tokens.test.js && node test/project-usage.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/notification-events.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js && node test/security-private.test.js && node test/security-idor.test.js && node test/security-ssrf.test.js && node test/deploy-wrapper.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"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.70.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",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -412,6 +412,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -412,6 +412,17 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<p>Own two accounts? Fold one into the other. Its follows, OpenCoins, linked sign-ins, sessions and settings move to the account you keep, which keeps its name and profile. The other account can't be used on its own afterwards: signing in to it signs you in to the one you kept. If it was a mistake, support can split it by hand within 30 days.</p>
<p>Own two accounts? Fold one into the other. Its follows, OpenCoins, linked sign-ins, sessions and settings move to the account you keep, which keeps its name and profile. The other account can't be used on its own afterwards: signing in to it signs you in to the one you kept. If it was a mistake, support can split it by hand within 30 days.</p>
<div id="merge-body" style="margin-top:12px"></div>
<div id="merge-body" style="margin-top:12px"></div>
</div>
</div>
+ <!-- Your data (ADR-033): a copy of everything, and deleting the account after 30 days. -->
+ <div class="card" id="export-card" style="margin-top:16px">
+ <h3><span class="icon"><i class="fa-solid fa-file-zipper"></i></span> Download your data</h3>
+ <p>Get a copy of what OpenVibe keeps about you: your account here, and your part of Live, Chat, Community, Media and Games, as JSON files in one .zip. Each service adds its own files, so it takes up to half an hour. You can download it for 7 days, and ask for one a day.</p>
+ <div id="export-body" style="margin-top:12px"></div>
+ </div>
+ <div class="card" id="delete-card" style="margin-top:16px">
+ <h3><span class="icon"><i class="fa-solid fa-user-xmark"></i></span> Delete your account</h3>
+ <p>Your account is deleted 30 days after you ask, and you can cancel any time before then; until that date everything works as usual. On the day, your account here is erased and every service erases what it keeps about you: messages, posts, follows, media and settings. Replies other people wrote under your posts stay, without your name. Payment records are kept without your name, as the law requires. After that it cannot be undone, and your username becomes free for someone else.</p>
+ <div id="delete-body" style="margin-top:12px"></div>
+ </div>
</div>
</div>
<!-- Notifications Section -->
<!-- Notifications Section -->
@@ -1212,6 +1223,7 @@ async function loadUser() {
@@ -1212,6 +1223,7 @@ async function loadUser() {
loadThemes(); loadThemeSubmissions();
loadThemes(); loadThemeSubmissions();
loadAccounts();
loadAccounts();
renderMerge();
renderMerge();
+ renderExport(); renderDeletion();
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
loadNotifPrefs();
loadNotifPrefs();
loadLinked();
loadLinked();
@@ -1452,6 +1464,128 @@ function renderMerge() {
@@ -1452,6 +1464,128 @@ function renderMerge() {
row.append(doIt, cancel); body.appendChild(row);
row.append(doIt, cancel); body.appendChild(row);
}
}
+// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and
+// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).
+async function accountApi(path, opts = {}) {
+ const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
+ const t = getAuthToken();
+ if (t) headers.Authorization = 'Bearer ' + t;
+ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
+ let body = {};
+ try { body = await res.json(); } catch { /* */ }
+ return { status: res.status, ok: res.ok, body };
+}
+const ovEl = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
+const whenText = (s) => new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
+const sizeText = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
+let exportPoll = null;
+async function renderExport() {
+ const body = document.getElementById('export-body');
+ if (!body || !currentUser) return;
+ clearTimeout(exportPoll);
+ body.textContent = '';
+ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to export.')); return; }
+ const r = await accountApi('/api/v1/account/export');
+ const latest = ((r.body && r.body.exports) || [])[0];
+ const row = ovEl('div', '', 'btn-row');
+ if (latest && latest.status === 'pending') {
+ const waiting = latest.services.filter((s) => s.status === 'waiting').map((s) => s.service);
+ body.appendChild(ovEl('p', `Gathering your data since ${whenText(latest.requested_at)}${waiting.length ? `; waiting for ${waiting.join(', ')}` : ''}. It is ready by ${whenText(latest.deadline)} at the latest, and you get a notification.`));
+ exportPoll = setTimeout(renderExport, 15000);
+ return;
+ }
+ if (latest && (latest.status === 'ready' || latest.status === 'partial')) {
+ const missing = latest.services.filter((s) => s.status === 'missing').map((s) => s.service);
+ body.appendChild(ovEl('p', `Ready since ${whenText(latest.ready_at)} (${sizeText(latest.size_bytes || 0)}), until ${whenText(latest.expires_at)}.${missing.length ? ` Not included, because they did not answer in time: ${missing.join(', ')}. Ask again tomorrow for a complete copy.` : ''}`));
+ const dl = ovEl('button', 'Download (.zip)', 'btn btn-primary');
+ dl.type = 'button';
+ dl.onclick = () => downloadExport(latest.export_id, dl);
+ row.appendChild(dl);
+ } else if (latest && latest.status === 'failed') {
+ body.appendChild(ovEl('p', 'The last export could not be built. Ask again.'));
+ }
+ const since = latest && latest.status !== 'failed' ? Date.now() - Date.parse(latest.requested_at) : Infinity;
+ if (since >= 86400000) {
+ const start = ovEl('button', latest ? 'Request a new copy' : 'Request my data', latest ? 'btn btn-outline' : 'btn btn-primary');
+ start.type = 'button';
+ start.onclick = async () => {
+ start.disabled = true;
+ const s = await accountApi('/api/v1/account/export', { method: 'POST', body: '{}' });
+ if (!s.ok) { start.disabled = false; toast(s.body.detail || 'Could not start the export', 'error'); return; }
+ toast('Gathering your data. You get a notification when it is ready.', 'success');
+ renderExport();
+ };
+ row.appendChild(start);
+ } else if (latest) {
+ body.appendChild(ovEl('p', `You can ask for a new copy from ${whenText(Date.parse(latest.requested_at) + 86400000)}.`));
+ }
+ if (row.children.length) body.appendChild(row);
+}
+async function downloadExport(id, btn) {
+ btn.disabled = true;
+ try {
+ const headers = {};
+ const t = getAuthToken();
+ if (t) headers.Authorization = 'Bearer ' + t;
+ const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: 'include' });
+ if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }
+ const blob = await res.blob();
+ const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(blob);
+ a.download = name;
+ document.body.appendChild(a); a.click(); a.remove();
+ setTimeout(() => URL.revokeObjectURL(a.href), 60000);
+ } catch { toast('The download failed. Try again.', 'error'); } finally { btn.disabled = false; }
+}
+async function renderDeletion() {
+ const body = document.getElementById('delete-body');
+ if (!body || !currentUser) return;
+ body.textContent = '';
+ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to delete.')); return; }
+ const r = await accountApi('/api/v1/account/deletion');
+ const d = r.body && r.body.deletion;
+ const row = ovEl('div', '', 'btn-row');
+ if (d) {
+ body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));
+ const keep = ovEl('button', 'Keep my account', 'btn btn-primary');
+ keep.type = 'button';
+ keep.onclick = async () => {
+ keep.disabled = true;
+ const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });
+ if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');
+ renderDeletion();
+ };
+ row.appendChild(keep); body.appendChild(row);
+ return;
+ }
+ const field = ovEl('div', '', 'form-field');
+ const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);
+ const input = ovEl('input');
+ input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.id = 'delete-confirm';
+ label.htmlFor = 'delete-confirm';
+ field.append(label, input);
+ const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');
+ go.type = 'button';
+ go.onclick = async () => {
+ go.disabled = true;
+ const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });
+ go.disabled = false;
+ if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {
+ toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');
+ const again = ovEl('button', 'Sign in again', 'btn btn-outline');
+ again.type = 'button'; again.onclick = addAccount;
+ if (!row.querySelector('.btn-outline')) row.appendChild(again);
+ return;
+ }
+ if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }
+ toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');
+ renderDeletion();
+ };
+ row.appendChild(go);
+ body.append(field, row);
+}
+
function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
@@ -41,6 +41,8 @@ function verifySession(token, { db, publicKey, config }) {
@@ -41,6 +41,8 @@ function verifySession(token, { db, publicKey, config }) {
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
if (user.is_banned) return { error: 'Account banned', status: 403, ban_reason: user.ban_reason };
// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
// An account merged into another (ADR-029) has no tokens of its own any more; signing in lands on the survivor.
if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
if (user.merged_into) return { error: 'Account merged', status: 401, merged: true };
+ // A deleted account (ADR-033) is a tombstone: none of its tokens is accepted.
+ if (user.deleted_at) return { error: 'Account deleted', status: 401, deleted: true };
if (user.token_valid_after) {
if (user.token_valid_after) {
const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
const validAfter = new Date(user.token_valid_after + (user.token_valid_after.includes('Z') ? '' : 'Z')).getTime();
if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
if (decoded.iat * 1000 < validAfter) return { error: 'Token revoked', status: 401 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,581 @@
@@ -0,0 +1,581 @@
+'use strict';
+/**
+ * Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0).
+ *
+ * Export. The signed-in person starts a job (POST /api/v1/account/export: one open at a time, one a day). Network
+ * queues network.account.export_requested; each service holding network.account.export.contribute pushes its part
+ * (POST /internal/account-exports/:id/parts, network.account-export-part@1: JSON files of the subject's own rows).
+ * When every expected service has answered, or at the 30-minute deadline, Network adds its own part and writes one
+ * zip: README.txt, network/…, <service>/…. It stays downloadable by the same person for 7 days, then it is deleted.
+ *
+ * Deletion. A fresh sign-in (auth_time within 10 minutes) and the username typed out schedule it 30 days ahead
+ * (POST /api/v1/account/deletion); until then the account works and DELETE cancels it. At the date, ONE transaction
+ * erases what Network owns:
+ * - providers, sessions, OAuth tokens and codes, reset and verification tokens;
+ * - modules, follows and blocks both ways, notifications, preferences, push subscriptions, effects;
+ * - history, username history, the sign-in IP log and email log;
+ * - project memberships; a project the person owns passes to another owner or admin, else it is archived and
+ * its apps revoked.
+ * The OpenCoins balance is closed with one ledger entry (account_deleted); ledger rows stay. The user row becomes
+ * a tombstone (username released, email, password and profile cleared, deleted_at), as do accounts merged into it;
+ * the subject is never reused and resolves as deleted. Its tokens are revoked strictly, and network.account.deleted
+ * is queued. Each service erases its own rows and confirms (POST /internal/account-deletions/:id/confirmations,
+ * network.account-deletion-confirmation@1); staff (staff.users.manage) see what is still outstanding.
+ */
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const { ids, validate, staff } = require('openvibe-contracts');
+const eventRelay = require('../developer/event-relay');
+const wallet = require('../coins/wallet');
+const revocation = require('../auth/revocation');
+const zip = require('../utils/zip');
+
+const EXPORT_DEADLINE_MS = 30 * 60 * 1000;
+const EXPORT_KEEP_MS = 7 * 86400000;
+const EXPORT_EVERY_MS = 24 * 3600 * 1000;
+const PART_MAX_BYTES = 20 * 1024 * 1024;
+const GRACE_MS = 30 * 86400000;
+const FRESH_SIGN_IN_S = 10 * 60;
+const ROW_LIMIT = 5000;
+const CONTRIBUTE = 'network.account.export.contribute';
+const CONFIRM = 'network.account.deletion.confirm';
+const SELF_AUDIENCE = 'openvibe.network';
+const ID_RE = { exp: /^exp_[0-9A-HJKMNP-TV-Z]{26}$/, del: /^del_[0-9A-HJKMNP-TV-Z]{26}$/ };
+// Columns never exported: secrets, their hashes, one-time codes, push keys.
+const SECRET_COL = /(^|_)(token|secret|hash|password|code|p256dh|auth|keys?)($|_)/i;
+
+class AccountDataError extends Error {
+ constructor(status, code, detail, extra) { super(detail); this.status = status; this.code = code; this.extra = extra; }
+}
+
+const iso = (ms) => new Date(ms).toISOString();
+
+function ensureSchema(db) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS account_exports (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'ready', 'partial', 'expired', 'failed')),
+ expected TEXT NOT NULL DEFAULT '[]',
+ requested_at TEXT NOT NULL,
+ deadline TEXT NOT NULL,
+ ready_at TEXT,
+ expires_at TEXT,
+ size_bytes INTEGER,
+ services TEXT,
+ error TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_account_exports_user ON account_exports(user_id, requested_at);
+ CREATE TABLE IF NOT EXISTS account_export_parts (
+ export_id TEXT NOT NULL,
+ service TEXT NOT NULL,
+ received_at TEXT NOT NULL,
+ files INTEGER NOT NULL,
+ bytes INTEGER NOT NULL,
+ body TEXT,
+ PRIMARY KEY (export_id, service)
+ );
+ CREATE TABLE IF NOT EXISTS account_deletions (
+ id TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL,
+ subject TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'cancelled', 'deleted')),
+ requested_at TEXT NOT NULL,
+ delete_after TEXT NOT NULL,
+ cancelled_at TEXT,
+ cancelled_by TEXT,
+ deleted_at TEXT,
+ expected TEXT,
+ erased TEXT
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_account_deletions_open ON account_deletions(user_id) WHERE status = 'scheduled';
+ CREATE TABLE IF NOT EXISTS account_deletion_confirmations (
+ deletion_id TEXT NOT NULL,
+ service TEXT NOT NULL,
+ received_at TEXT NOT NULL,
+ completed_at TEXT NOT NULL,
+ erased TEXT NOT NULL,
+ retained TEXT NOT NULL,
+ PRIMARY KEY (deletion_id, service)
+ );
+ `);
+ const cols = db.prepare('PRAGMA table_info(users)').all().map((c) => c.name);
+ if (!cols.includes('deleted_at')) db.exec('ALTER TABLE users ADD COLUMN deleted_at TEXT');
+}
+
+const tableExists = (db, name) => !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name);
+const columnsOf = (db, table) => db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name);
+
+/** The services expected to answer: every principal holding the capability for Network. */
+function expectedServices(db, capability) {
+ if (!tableExists(db, 'principal_grants')) return [];
+ return db.prepare(`SELECT DISTINCT client_id FROM principal_grants WHERE capability = ? AND audience = ? AND revoked_at IS NULL
+ AND (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP) ORDER BY client_id`).all(capability, SELF_AUDIENCE).map((r) => r.client_id);
+}
+
+function envelope(type, payload, subject, at) {
+ const env = {
+ event_id: ids.newId('event', Date.parse(at)), event_type: type, version: 1, source: 'network',
+ actor: { type: 'user', id: subject }, timestamp: at, visibility: 'internal', subject: { type: 'user', id: subject }, payload,
+ };
+ const v = validate('events.event-envelope@1', env);
+ const pv = validate(`${type}@1`, payload);
+ if (!v.valid || !pv.valid) throw new Error(`account-data: bad ${type}: ${JSON.stringify((v.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
+ return env;
+}
+
+function kick(db) { try { const live = eventRelay.outboxFor(db); if (live) live.kick(); } catch { /* the relay polls anyway */ } }
+
+function assertPerson(user) {
+ if (!user || user.is_anon) throw new AccountDataError(400, 'account.guest', 'a guest account has nothing to export or delete');
+ if (user.merged_into) throw new AccountDataError(409, 'account.merged', 'this account was merged into another');
+ if (user.deleted_at) throw new AccountDataError(410, 'account.deleted', 'this account was deleted');
+ if (!ids.isSubjectId('user', user.subject_id)) throw new AccountDataError(400, 'account.no_subject', 'this account has no subject');
+}
+
+// ── Export ─────────────────────────────────────────────────────
+
+function exportView(db, row) {
+ const parts = db.prepare('SELECT service, files, bytes FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
+ const got = new Map(parts.map((p) => [p.service, p]));
+ const built = row.services ? JSON.parse(row.services) : null;
+ const services = built || [...new Set([...JSON.parse(row.expected || '[]'), ...got.keys()])].sort().map((s) => (got.has(s)
+ ? { service: s, status: 'received', files: got.get(s).files, bytes: got.get(s).bytes }
+ : { service: s, status: row.status === 'pending' ? 'waiting' : 'missing' }));
+ const out = { export_id: row.id, status: row.status, requested_at: row.requested_at, deadline: row.deadline, services };
+ if (row.ready_at) out.ready_at = row.ready_at;
+ if (row.expires_at) out.expires_at = row.expires_at;
+ if (row.size_bytes != null) out.size_bytes = row.size_bytes;
+ return out;
+}
+
+/** The person starts an export → { status: 201|200, body: network.account-export@1 }. */
+function startExport(db, user, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ assertPerson(user);
+ const open = db.prepare("SELECT * FROM account_exports WHERE user_id = ? AND status = 'pending' ORDER BY requested_at DESC LIMIT 1").get(user.id);
+ if (open) return { status: 200, row: open, body: exportView(db, open) };
+ const recent = db.prepare("SELECT requested_at FROM account_exports WHERE user_id = ? AND status != 'failed' AND requested_at > ? ORDER BY requested_at DESC LIMIT 1")
+ .get(user.id, iso(now - EXPORT_EVERY_MS));
+ if (recent) {
+ const retry = Math.ceil((Date.parse(recent.requested_at) + EXPORT_EVERY_MS - now) / 1000);
+ throw new AccountDataError(429, 'export.too_soon', 'one export a day: the last one is still available below', { retry_after: retry });
+ }
+ const id = `exp_${ids.ulid(now)}`;
+ const requestedAt = iso(now);
+ const deadline = iso(now + EXPORT_DEADLINE_MS);
+ const expected = expectedServices(db, CONTRIBUTE);
+ db.transaction(() => {
+ db.prepare('INSERT INTO account_exports (id, user_id, subject, expected, requested_at, deadline) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(id, user.id, user.subject_id, JSON.stringify(expected), requestedAt, deadline);
+ eventRelay.writerFor(db).enqueue(envelope('network.account.export_requested', { export_id: id, subject: user.subject_id, requested_at: requestedAt, deadline }, user.subject_id, requestedAt));
+ })();
+ kick(db);
+ const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(id);
+ return { status: 201, row, body: exportView(db, row) };
+}
+
+/** A service's part → network.account-data-receipt@1. */
+function receivePart(db, exportId, service, body, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ const v = validate('network.account-export-part@1', body);
+ if (!v.valid) throw new AccountDataError(400, 'export.invalid_part', JSON.stringify(v.errors).slice(0, 300));
+ const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId) : null;
+ if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
+ if (body.subject !== row.subject) throw new AccountDataError(400, 'export.wrong_subject', "the part's subject is not the export's");
+ if (row.status !== 'pending' || now > Date.parse(row.deadline)) throw new AccountDataError(409, 'export.closed', 'this export was already built');
+ const names = body.files.map((f) => f.name);
+ if (new Set(names).size !== names.length) throw new AccountDataError(400, 'export.duplicate_file', 'two files with the same name');
+ const text = JSON.stringify(body);
+ const bytes = Buffer.byteLength(text);
+ if (bytes > PART_MAX_BYTES) throw new AccountDataError(413, 'export.part_too_large', 'a part is at most 20 MB');
+ const had = !!db.prepare('SELECT 1 FROM account_export_parts WHERE export_id = ? AND service = ?').get(row.id, service);
+ db.prepare('INSERT OR REPLACE INTO account_export_parts (export_id, service, received_at, files, bytes, body) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(row.id, service, iso(now), body.files.length, bytes, text);
+ return { id: row.id, service, received_at: iso(now), replaced: had };
+}
+
+/** Rows about a person from one table, secrets left out, newest first, at most ROW_LIMIT. */
+function rowsOf(db, table, where, params, { omit = [] } = {}) {
+ if (!tableExists(db, table)) return { rows: [], truncated: false };
+ const cols = columnsOf(db, table).filter((c) => !SECRET_COL.test(c) && !omit.includes(c));
+ if (!cols.length) return { rows: [], truncated: false };
+ const rows = db.prepare(`SELECT ${cols.join(', ')} FROM ${table} WHERE ${where} ORDER BY rowid DESC LIMIT ${ROW_LIMIT + 1}`).all(...params);
+ return { rows: rows.slice(0, ROW_LIMIT), truncated: rows.length > ROW_LIMIT };
+}
+
+/** Network's own part: { files: [{ name, content }], truncated: [] }. */
+function networkPart(db, userId) {
+ const u = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
+ const files = [];
+ const truncated = [];
+ const add = (name, content, cut) => { files.push({ name, content }); if (cut) truncated.push(name); };
+ const keep = ['id', 'username', 'email', 'display_name', 'avatar_url', 'bio', 'role', 'profile_color', 'created_at', 'updated_at', 'last_seen',
+ 'email_verified', 'email_verified_at', 'name_effect', 'particle_effect', 'history_paused', 'subject_id'];
+ const account = {};
+ for (const k of keep) if (k in u) account[k] = u[k];
+ account.merged_in = tableExists(db, 'subject_aliases')
+ ? db.prepare('SELECT alias_id AS subject, merged_at FROM subject_aliases WHERE subject_id = ? ORDER BY merged_at').all(u.subject_id) : [];
+ add('account.json', account);
+ const byUser = (name, table, opts) => { const r = rowsOf(db, table, 'user_id = ?', [u.id], opts); add(name, r.rows, r.truncated); };
+ byUser('sign_in_providers.json', 'linked_accounts');
+ byUser('sessions.json', 'user_sessions');
+ byUser('oauth_grants.json', 'oauth_tokens');
+ byUser('notifications.json', 'notifications');
+ add('preferences.json', {
+ display: rowsOf(db, 'user_preferences', 'user_id = ?', [u.id]).rows[0] || null,
+ notifications: rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id]).rows,
+ effects: rowsOf(db, 'user_effects', 'user_id = ?', [u.id]).rows,
+ push_devices: rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] }).rows,
+ });
+ byUser('history.json', 'user_history');
+ add('username_history.json', rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] }).rows);
+ byUser('profile_changes.json', 'user_profile_changes');
+ byUser('sign_in_log.json', 'ip_log');
+ byUser('emails_sent.json', 'email_delivery_log');
+ const coins = rowsOf(db, 'coin_transactions', 'user_id = ?', [u.id]);
+ add('opencoins.json', { balance: (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(u.id) || { balance: 0 }).balance, ledger: coins.rows }, coins.truncated);
+ add('modules.json', tableExists(db, 'user_modules') ? db.prepare('SELECT namespace, version, revision, data, updated_at FROM user_modules WHERE subject_id = ? ORDER BY namespace').all(u.subject_id)
+ .map((m) => { let data = m.data; try { data = JSON.parse(m.data); } catch { /* kept as text */ } return { ...m, data }; }) : []);
+ const follows = rowsOf(db, 'user_follows', 'follower_subject = ?', [u.subject_id]);
+ const followers = tableExists(db, 'user_follows') ? db.prepare("SELECT COUNT(*) AS n FROM user_follows WHERE target_id = ? AND active = 1").get(u.subject_id).n : 0;
+ add('follows.json', { following: follows.rows, followers_count: followers }, follows.truncated);
+ add('blocks.json', rowsOf(db, 'user_blocks', 'blocker_subject = ?', [u.subject_id]).rows);
+ add('developer_projects.json', {
+ owned: rowsOf(db, 'dev_projects', 'owner_subject = ?', [u.subject_id]).rows,
+ memberships: rowsOf(db, 'dev_project_members', 'subject_id = ?', [u.subject_id], { omit: ['added_by'] }).rows,
+ });
+ add('account_merges.json', tableExists(db, 'account_merges')
+ ? db.prepare('SELECT id, from_subject, into_subject, initiated_by, reason, moved, merged_at FROM account_merges WHERE into_user_id = ? ORDER BY merged_at').all(u.id) : []);
+ add('moderation.json', rowsOf(db, 'moderation_audit', 'target_subject = ?', [u.subject_id], { omit: ['actor_subject'] }).rows);
+ return { files, truncated };
+}
+
+function readme(row, sections, now) {
+ const lines = [
+ 'OpenVibe account export',
+ `Export ${row.id} for ${row.subject}, requested ${row.requested_at}, built ${iso(now)}.`,
+ '',
+ 'Each folder is one service\'s part: the rows it keeps about you, as JSON. Secrets (passwords, tokens and their',
+ 'hashes) are never included. Media files themselves are downloaded from the links in the media part.',
+ '',
+ ];
+ for (const s of sections) {
+ if (s.status !== 'received') { lines.push(`${s.service}: MISSING (it did not answer before ${row.deadline}; ask for a new export later)`); continue; }
+ lines.push(`${s.service}: ${s.files} file(s)${s.truncated.length ? `; cut at the service's row limit: ${s.truncated.join(', ')}` : ''}${s.note ? ` (${s.note})` : ''}`);
+ }
+ return lines.join('\n') + '\n';
+}
+
+/** Build the archive when every expected service answered, at the deadline, or when forced. → true if built. */
+function maybeBuild(db, exportId, { dir, now = Date.now(), force = false, notify } = {}) {
+ ensureSchema(db);
+ const row = db.prepare('SELECT * FROM account_exports WHERE id = ?').get(exportId);
+ if (!row || row.status !== 'pending') return false;
+ const expected = JSON.parse(row.expected || '[]');
+ const parts = db.prepare('SELECT service, received_at, files, bytes, body FROM account_export_parts WHERE export_id = ? ORDER BY service').all(row.id);
+ const got = new Set(parts.map((p) => p.service));
+ if (!force && now < Date.parse(row.deadline) && !expected.every((s) => got.has(s))) return false;
+ try {
+ const own = networkPart(db, row.user_id);
+ const sections = [{ service: 'network', status: 'received', files: own.files.length, truncated: own.truncated }];
+ const entries = own.files.map((f) => ({ name: `network/${f.name}`, data: JSON.stringify(f.content, null, 2) }));
+ for (const p of parts) {
+ const b = JSON.parse(p.body || '{"files":[]}');
+ sections.push({ service: p.service, status: 'received', files: b.files.length, truncated: b.truncated || [], note: b.note });
+ for (const f of b.files) entries.push({ name: `${p.service}/${f.name}`, data: JSON.stringify(f.content, null, 2) });
+ }
+ for (const s of expected) if (!got.has(s)) sections.push({ service: s, status: 'missing' });
+ entries.unshift({ name: 'README.txt', data: readme(row, sections, now) });
+ const buf = zip.build(entries, { date: new Date(now) });
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ const file = path.join(dir, `${row.id}.zip`);
+ fs.writeFileSync(file, buf, { mode: 0o600 });
+ const status = expected.every((s) => got.has(s)) ? 'ready' : 'partial';
+ const services = sections.map((s) => (s.status === 'received'
+ ? { service: s.service, status: 'received', files: s.files, bytes: s.service === 'network' ? Buffer.byteLength(JSON.stringify(own.files)) : parts.find((p) => p.service === s.service).bytes }
+ : { service: s.service, status: 'missing' }));
+ db.transaction(() => {
+ db.prepare('UPDATE account_exports SET status = ?, ready_at = ?, expires_at = ?, size_bytes = ?, services = ? WHERE id = ?')
+ .run(status, iso(now), iso(now + EXPORT_KEEP_MS), buf.length, JSON.stringify(services), row.id);
+ db.prepare('UPDATE account_export_parts SET body = NULL WHERE export_id = ?').run(row.id);
+ })();
+ if (notify) {
+ try {
+ notify(row.user_id, { title: 'Your data export is ready', message: status === 'ready' ? 'Download it from your account page within 7 days.' : 'Some services did not answer in time; the archive says which. Download it from your account page within 7 days.' });
+ } catch { /* the page shows it anyway */ }
+ }
+ return true;
+ } catch (e) {
+ db.prepare("UPDATE account_exports SET status = 'failed', error = ? WHERE id = ?").run(String(e.message).slice(0, 300), row.id);
+ console.error('[AccountData] export build failed:', row.id, e.message);
+ return false;
+ }
+}
+
+function exportFile(db, user, exportId, { dir, now = Date.now() } = {}) {
+ ensureSchema(db);
+ const row = ID_RE.exp.test(String(exportId)) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(exportId, user.id) : null;
+ if (!row) throw new AccountDataError(404, 'export.not_found', 'no such export');
+ if (row.status === 'pending') throw new AccountDataError(409, 'export.pending', 'this export is still being gathered');
+ if (!['ready', 'partial'].includes(row.status) || Date.parse(row.expires_at) < now) throw new AccountDataError(410, 'export.expired', 'this export is no longer available');
+ return { row, file: path.join(dir, `${row.id}.zip`) };
+}
+
+// ── Deletion ───────────────────────────────────────────────────
+
+function deletionView(row) {
+ const out = { deletion_id: row.id, status: row.status, requested_at: row.requested_at, delete_after: row.delete_after };
+ if (row.cancelled_at) out.cancelled_at = row.cancelled_at;
+ if (row.deleted_at) out.deleted_at = row.deleted_at;
+ return out;
+}
+
+function scheduleDeletion(db, user, claims, confirmUsername, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ assertPerson(user);
+ const open = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(user.id);
+ if (open) return { status: 200, body: deletionView(open) };
+ const owner = (process.env.OWNER_USERNAME || 'goosely').toLowerCase();
+ if (String(user.username || '').toLowerCase() === owner) throw new AccountDataError(403, 'deletion.owner', "the owner's account cannot be deleted here");
+ const authTime = Number(claims && claims.auth_time);
+ if (!Number.isFinite(authTime) || now / 1000 - authTime > FRESH_SIGN_IN_S) {
+ throw new AccountDataError(401, 'deletion.sign_in_again', 'sign in again (within the last 10 minutes) to delete this account');
+ }
+ if (String(confirmUsername || '').trim().toLowerCase() !== String(user.username).toLowerCase()) {
+ throw new AccountDataError(400, 'deletion.confirm_username', 'type your username exactly to confirm');
+ }
+ const id = `del_${ids.ulid(now)}`;
+ db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')
+ .run(id, user.id, user.subject_id, iso(now), iso(now + GRACE_MS));
+ return { status: 201, body: deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(id)) };
+}
+
+function cancelDeletion(db, userId, { by = 'person', now = Date.now() } = {}) {
+ ensureSchema(db);
+ const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(userId);
+ if (!row) return null;
+ db.prepare("UPDATE account_deletions SET status = 'cancelled', cancelled_at = ?, cancelled_by = ? WHERE id = ?").run(iso(now), by, row.id);
+ return deletionView(db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(row.id));
+}
+
+/** Erase what Network owns for one due deletion, in one transaction, and queue network.account.deleted. → counts. */
+function erase(db, deletion, { now = Date.now() } = {}) {
+ const at = iso(now);
+ const erased = {};
+ const count = (k, n) => { if (n) erased[k] = (erased[k] || 0) + n; };
+ const run = db.transaction(() => {
+ const user = db.prepare('SELECT * FROM users WHERE id = ?').get(deletion.user_id);
+ if (!user) throw new Error(`account-data: no user ${deletion.user_id}`);
+ const subject = user.subject_id;
+ const folded = db.prepare('SELECT id, subject_id FROM users WHERE merged_into = ?').all(user.id);
+ const aliases = tableExists(db, 'subject_aliases') ? db.prepare('SELECT alias_id FROM subject_aliases WHERE subject_id = ?').all(subject).map((r) => r.alias_id) : [];
+ const subjects = [...new Set([subject, ...aliases, ...folded.map((f) => f.subject_id).filter(Boolean)])];
+ const userIds = [user.id, ...folded.map((f) => f.id)];
+ const inU = `(${userIds.map(() => '?').join(',')})`;
+ const inS = `(${subjects.map(() => '?').join(',')})`;
+ const del = (table, where, params, key = table) => { if (tableExists(db, table)) count(key, db.prepare(`DELETE FROM ${table} WHERE ${where}`).run(...params).changes); };
+
+ for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
+ 'notification_preferences', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
+ 'ip_log', 'email_delivery_log']) del(t, `user_id IN ${inU}`, userIds);
+ if (tableExists(db, 'account_merge_intents')) del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
+ // Notifications this person sent others stay, without their name or picture.
+ if (tableExists(db, 'notifications')) db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
+ if (tableExists(db, 'analytics_events')) db.prepare(`UPDATE analytics_events SET user_id = NULL WHERE user_id IN ${inU}`).run(...userIds);
+ del('user_modules', `subject_id IN ${inS}`, subjects, 'modules');
+ del('user_follows', `follower_subject IN ${inS} OR target_id IN ${inS}`, [...subjects, ...subjects], 'follows');
+ del('user_blocks', `blocker_subject IN ${inS} OR blocked_subject IN ${inS}`, [...subjects, ...subjects], 'blocks');
+ if (tableExists(db, 'dev_projects')) {
+ for (const p of db.prepare(`SELECT id FROM dev_projects WHERE owner_subject IN ${inS} AND archived_at IS NULL`).all(...subjects)) {
+ const heir = db.prepare(`SELECT subject_id FROM dev_project_members WHERE project_id = ? AND subject_id NOT IN ${inS} AND role IN ('owner', 'admin')
+ ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END, added_at LIMIT 1`).get(p.id, ...subjects);
+ if (heir) { db.prepare('UPDATE dev_projects SET owner_subject = ? WHERE id = ?').run(heir.subject_id, p.id); count('projects_passed_on', 1); continue; }
+ for (const a of db.prepare('SELECT id FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(p.id)) {
+ db.prepare("UPDATE dev_apps SET revoked_at = ?, revoked_by = 'account_deleted' WHERE id = ?").run(at, a.id);
+ db.prepare("UPDATE dev_credentials SET revoked_at = ?, revoked_by = 'account_deleted' WHERE app_id = ? AND revoked_at IS NULL").run(at, a.id);
+ }
+ db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
+ count('projects_archived', 1);
+ }
+ del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
+ }
+ if (tableExists(db, 'account_merges')) db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
+ // OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
+ for (const id of userIds) {
+ const bal = (db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(id) || { balance: 0 }).balance;
+ if (bal > 0) {
+ wallet.debit(db, { user_id: id, app_id: 'network', amount: bal, reason: 'account_deleted', ref: deletion.id, idempotency_key: `delete:${deletion.id}:${id}` });
+ count('opencoins_closed', bal);
+ }
+ }
+ // The tombstones: the username is released, the rest cleared; the subject stays (never reused).
+ const cols = columnsOf(db, 'users');
+ for (const id of userIds) {
+ const u = db.prepare('SELECT subject_id FROM users WHERE id = ?').get(id);
+ const name = `deleted-${String(u.subject_id || id).slice(-12).toLowerCase()}`;
+ const set = { username: name, email: null, password_hash: '!deleted', display_name: null, avatar_url: null, bio: '', profile_color: null, ban_reason: null,
+ name_effect: null, particle_effect: null, email_verified: 0, email_verified_at: null, email_bounced_at: null, email_bounce_reason: null, deleted_at: at };
+ const keys = Object.keys(set).filter((k) => cols.includes(k));
+ db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => set[k]), id);
+ count('accounts', 1);
+ }
+ revocation.revokeTokens(db, user.id, { reason: 'account_deleted', actor: { type: 'user', id: subject }, strict: true });
+ const expected = expectedServices(db, CONFIRM);
+ db.prepare("UPDATE account_deletions SET status = 'deleted', deleted_at = ?, expected = ?, erased = ? WHERE id = ?")
+ .run(at, JSON.stringify(expected), JSON.stringify(erased), deletion.id);
+ const payload = { deletion_id: deletion.id, subject, requested_at: deletion.requested_at, deleted_at: at };
+ const others = subjects.filter((s) => s !== subject).slice(0, 50);
+ if (others.length) payload.aliases = others;
+ eventRelay.writerFor(db).enqueue(envelope('network.account.deleted', payload, subject, at));
+ });
+ run();
+ kick(db);
+ return erased;
+}
+
+/** A service confirms a deletion → network.account-data-receipt@1. */
+function receiveConfirmation(db, deletionId, service, body, { now = Date.now() } = {}) {
+ ensureSchema(db);
+ const v = validate('network.account-deletion-confirmation@1', body);
+ if (!v.valid) throw new AccountDataError(400, 'deletion.invalid_confirmation', JSON.stringify(v.errors).slice(0, 300));
+ const row = ID_RE.del.test(String(deletionId)) ? db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId) : null;
+ if (!row) throw new AccountDataError(404, 'deletion.not_found', 'no such deletion');
+ if (body.subject !== row.subject) throw new AccountDataError(400, 'deletion.wrong_subject', "the confirmation's subject is not the deletion's");
+ if (row.status !== 'deleted') throw new AccountDataError(409, 'deletion.not_deleted', 'this deletion has not happened');
+ const had = !!db.prepare('SELECT 1 FROM account_deletion_confirmations WHERE deletion_id = ? AND service = ?').get(row.id, service);
+ db.prepare('INSERT OR REPLACE INTO account_deletion_confirmations (deletion_id, service, received_at, completed_at, erased, retained) VALUES (?, ?, ?, ?, ?, ?)')
+ .run(row.id, service, iso(now), body.completed_at, JSON.stringify(body.erased), JSON.stringify(body.retained || {}));
+ return { id: row.id, service, received_at: iso(now), replaced: had };
+}
+
+/** The periodic sweep: build exports past their deadline, delete expired archives, carry out due deletions. */
+function sweep(db, { dir, now = Date.now(), notify } = {}) {
+ ensureSchema(db);
+ const out = { built: 0, expired: 0, deleted: 0 };
+ for (const r of db.prepare("SELECT id FROM account_exports WHERE status = 'pending' AND deadline <= ?").all(iso(now))) {
+ if (maybeBuild(db, r.id, { dir, now, force: true, notify })) out.built++;
+ }
+ for (const r of db.prepare("SELECT id FROM account_exports WHERE status IN ('ready', 'partial') AND expires_at <= ?").all(iso(now))) {
+ try { fs.rmSync(path.join(dir, `${r.id}.zip`), { force: true }); } catch { /* gone already */ }
+ db.prepare("UPDATE account_exports SET status = 'expired' WHERE id = ?").run(r.id);
+ out.expired++;
+ }
+ for (const d of db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' AND delete_after <= ?").all(iso(now))) {
+ try { const e = erase(db, d, { now }); out.deleted++; console.log(`[AccountData] deleted account ${d.subject} (${d.id}): ${JSON.stringify(e)}`); }
+ catch (e) { console.error('[AccountData] deletion failed:', d.id, e.message); }
+ }
+ return out;
+}
+
+function sendError(res, e) {
+ if (e instanceof AccountDataError) {
+ if (e.extra && e.extra.retry_after) res.set('Retry-After', String(e.extra.retry_after));
+ return res.status(e.status).json({ error: e.code, detail: e.message, ...(e.extra || {}) });
+ }
+ console.error('[AccountData]', e);
+ return res.status(500).json({ error: 'account_data.failed', detail: 'that did not work' });
+}
+
+/**
+ * Routers: `me` under /api/v1/account (the person), `internal` under /internal (service tokens: contributeGuard,
+ * confirmGuard) and `admin` under /api/admin/account-deletions (staff.users.manage).
+ */
+function routers({ requireAuth, staffClaims, contributeGuard, confirmGuard, dir, notify }) {
+ const me = express.Router();
+ me.use(express.json({ limit: '8kb' }));
+ const noStore = (res) => res.set('Cache-Control', 'private, no-store');
+ me.post('/export', requireAuth, (req, res) => {
+ const db = req.app.locals.db;
+ try {
+ const out = startExport(db, req.user);
+ if (out.status === 201) setImmediate(() => maybeBuild(db, out.row.id, { dir, notify }));
+ noStore(res).status(out.status).json(out.body);
+ } catch (e) { sendError(res, e); }
+ });
+ me.get('/export', requireAuth, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const rows = db.prepare('SELECT * FROM account_exports WHERE user_id = ? ORDER BY requested_at DESC LIMIT 5').all(req.user.id);
+ noStore(res).json({ exports: rows.map((r) => exportView(db, r)) });
+ });
+ me.get('/export/:id', requireAuth, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const row = ID_RE.exp.test(req.params.id) ? db.prepare('SELECT * FROM account_exports WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id) : null;
+ if (!row) return res.status(404).json({ error: 'export.not_found' });
+ noStore(res).json(exportView(db, row));
+ });
+ me.get('/export/:id/download', requireAuth, (req, res) => {
+ try {
+ const { row, file } = exportFile(req.app.locals.db, req.user, req.params.id, { dir });
+ if (!fs.existsSync(file)) return res.status(410).json({ error: 'export.expired' });
+ const day = row.ready_at.slice(0, 10);
+ noStore(res).set('Content-Type', 'application/zip')
+ .set('Content-Disposition', `attachment; filename="openvibe-${String(req.user.username).replace(/[^A-Za-z0-9_.-]/g, '_')}-${day}.zip"`)
+ .set('X-Content-Type-Options', 'nosniff');
+ fs.createReadStream(file).on('error', () => res.destroy()).pipe(res);
+ } catch (e) { sendError(res, e); }
+ });
+ me.get('/deletion', requireAuth, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const row = db.prepare("SELECT * FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(req.user.id);
+ noStore(res).json({ deletion: row ? deletionView(row) : null });
+ });
+ me.post('/deletion', requireAuth, (req, res) => {
+ try {
+ const out = scheduleDeletion(req.app.locals.db, req.user, req.tokenClaims, req.body && req.body.confirm_username);
+ noStore(res).status(out.status).json(out.body);
+ } catch (e) { sendError(res, e); }
+ });
+ me.delete('/deletion', requireAuth, (req, res) => {
+ const out = cancelDeletion(req.app.locals.db, req.user.id, { by: 'person' });
+ if (!out) return res.status(404).json({ error: 'deletion.not_scheduled' });
+ noStore(res).json(out);
+ });
+
+ const internal = express.Router();
+ const service = (req) => String((req.principal && req.principal.sub) || '').replace(/^svc:/, '');
+ internal.post('/account-exports/:id/parts', contributeGuard, express.json({ limit: '21mb' }), (req, res) => {
+ const db = req.app.locals.db;
+ try {
+ const receipt = receivePart(db, req.params.id, service(req), req.body);
+ setImmediate(() => maybeBuild(db, receipt.id, { dir, notify }));
+ res.json(receipt);
+ } catch (e) { sendError(res, e); }
+ });
+ internal.post('/account-deletions/:id/confirmations', confirmGuard, express.json({ limit: '64kb' }), (req, res) => {
+ try { res.json(receiveConfirmation(req.app.locals.db, req.params.id, service(req), req.body)); } catch (e) { sendError(res, e); }
+ });
+
+ const admin = express.Router();
+ admin.use(express.json({ limit: '8kb' }));
+ const staffOnly = (req, res, next) => (staff.can(staffClaims(req.user), 'staff.users.manage') ? next() : res.status(403).json({ error: 'forbidden', detail: 'staff.users.manage required' }));
+ admin.get('/', requireAuth, staffOnly, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const rows = db.prepare("SELECT * FROM account_deletions WHERE status = 'scheduled' OR deleted_at > ? ORDER BY requested_at DESC LIMIT 200").all(iso(Date.now() - 90 * 86400000));
+ const confirmed = db.prepare('SELECT service FROM account_deletion_confirmations WHERE deletion_id = ?');
+ noStore(res).json({
+ deletions: rows.map((r) => {
+ const got = confirmed.all(r.id).map((c) => c.service);
+ return { ...deletionView(r), subject: r.subject, confirmed: got, outstanding: JSON.parse(r.expected || '[]').filter((s) => !got.includes(s)) };
+ }),
+ });
+ });
+ admin.post('/:id/cancel', requireAuth, staffOnly, (req, res) => {
+ const db = req.app.locals.db; ensureSchema(db);
+ const reason = String((req.body && req.body.reason) || '').trim();
+ if (reason.length < 10) return res.status(400).json({ error: 'deletion.reason_required', detail: 'a written reason (10 characters or more)' });
+ const row = ID_RE.del.test(req.params.id) ? db.prepare("SELECT * FROM account_deletions WHERE id = ? AND status = 'scheduled'").get(req.params.id) : null;
+ if (!row) return res.status(404).json({ error: 'deletion.not_scheduled' });
+ const out = cancelDeletion(db, row.user_id, { by: `staff:${req.user.subject_id || req.user.id}` });
+ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'account_deletion_cancelled', JSON.stringify({ deletion_id: row.id, subject: row.subject, reason: reason.slice(0, 500) }));
+ res.json(out);
+ });
+ return { me, internal, admin };
+}
+
+module.exports = {
+ ensureSchema, startExport, receivePart, maybeBuild, networkPart, exportFile, exportView, scheduleDeletion, cancelDeletion, erase,
+ receiveConfirmation, sweep, routers, expectedServices, AccountDataError, GRACE_MS, EXPORT_DEADLINE_MS, EXPORT_KEEP_MS,
+};
@@ -55,6 +55,12 @@ const DEFAULT_GRANTS = [
@@ -55,6 +55,12 @@ const DEFAULT_GRANTS = [
// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).
// Media and Tools subscribe to network.user.token_valid_after too (their sign-in refuses older tokens).
['media', 'events.subscription.manage', 'openvibe.events', []],
['media', 'events.subscription.manage', 'openvibe.events', []],
['tools', 'events.subscription.manage', 'openvibe.events', []],
['tools', 'events.subscription.manage', 'openvibe.events', []],
+ // Account export and deletion (ADR-033): the services that keep data about people push their export part and
+ // confirm a deletion; the holders of each grant are the services Network waits for.
+ ...['live', 'chat', 'community', 'media', 'games'].flatMap((svc) => [
+ [svc, 'network.account.export.contribute', SELF_AUDIENCE, []],
+ [svc, 'network.account.deletion.confirm', SELF_AUDIENCE, []],
+ ]),
// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and
// Wave 9: Tips starts purchases and transfers in Billing, follows settlement through Events, and
// announces delivered tips in the creator's Live chat.
// announces delivered tips in the creator's Live chat.
['tips', 'billing.intent.create', 'openvibe.billing', []],
['tips', 'billing.intent.create', 'openvibe.billing', []],
@@ -109,8 +109,10 @@ const newGuestSubjectId = () => ids.newId('guest');
@@ -109,8 +109,10 @@ const newGuestSubjectId = () => ids.newId('guest');
function projection(db, subjectId) {
function projection(db, subjectId) {
if (typeof subjectId !== 'string') return null;
if (typeof subjectId !== 'string') return null;
if (subjectId.startsWith('usr_')) {
if (subjectId.startsWith('usr_')) {
- const u = db.prepare('SELECT id, subject_id, username, display_name, avatar_url, is_banned FROM users WHERE subject_id = ?').get(subjectId);
+ const u = db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
if (!u) return null;
if (!u) return null;
+ // A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
+ if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };
return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
}
}
@@ -161,7 +161,9 @@ app.use('/api/webhooks', require('./notifications/resend-webhook')());
@@ -161,7 +161,9 @@ app.use('/api/webhooks', require('./notifications/resend-webhook')());
// service exist (below); inert (503) until NETWORK_EVENTS_SECRET is set.
// service exist (below); inert (503) until NETWORK_EVENTS_SECRET is set.
let eventsConsumer = null;
let eventsConsumer = null;
app.use('/internal/events', (req, res, next) => (eventsConsumer ? eventsConsumer.router(req, res, next) : res.status(503).json({ error: 'starting' })));
app.use('/internal/events', (req, res, next) => (eventsConsumer ? eventsConsumer.router(req, res, next) : res.status(503).json({ error: 'starting' })));
-app.use(express.json({ limit: '1mb' }));
+// Export parts (ADR-033) carry up to 20 MB and are parsed by their own route.
+const jsonBody = express.json({ limit: '1mb' });
+app.use((req, res, next) => (req.method === 'POST' && /^\/internal\/account-exports\/[^/]+\/parts$/.test(req.path) ? next() : jsonBody(req, res, next)));
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
// ── CORS ─────────────────────────────────────────────────────
// ── CORS ─────────────────────────────────────────────────────
@@ -417,6 +419,23 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
@@ -417,6 +419,23 @@ app.use('/api/v1/me/blocks', rateLimit({ windowMs: 60_000, max: 60 }), require('
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), mergeRouters.me);
app.use('/api/admin/account-merges', mergeRouters.admin);
app.use('/api/admin/account-merges', mergeRouters.admin);
}
}
+// Account export and deletion (roadmap WS-B task 7, ADR-033): the person's export job and scheduled deletion;
+// services push export parts and confirm deletions with service tokens; staff (staff.users.manage) see what is
+// outstanding. Archives live next to the database for 7 days.
+const ACCOUNT_EXPORT_DIR = path.join(path.dirname(path.resolve(config.db.path)), 'account-exports');
+const notifyAccountData = (userId, n) => notificationService.create({ user_id: userId, type: 'GENERIC', category: 'system', service: 'network', url: 'https://openvibe.network/my#accounts', ...n });
+{
+ const accountData = require('./identity/account-data');
+ accountData.ensureSchema(db);
+ const guard = require('./identity/principals').guard;
+ const dataRouters = accountData.routers({
+ requireAuth, staffClaims: require('./auth/staff-claims').staffClaims, dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData,
+ contributeGuard: guard('network.account.export.contribute', { legacy: false }), confirmGuard: guard('network.account.deletion.confirm', { legacy: false }),
+ });
+ app.use('/api/v1/account', rateLimit({ windowMs: 60_000, max: 20 }), dataRouters.me);
+ app.use('/internal', dataRouters.internal);
+ app.use('/api/admin/account-deletions', dataRouters.admin);
+}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read', { legacy: false }), require('./identity/blocks').internalHandler(db));
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// The follow graph (WS-E task 4, ADR-030): public counts, a person's own follows, and who follows a target
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
// (its owner, or network.follows.read, service token only). Every change is network.follow.* (server/identity/follows.js).
@@ -837,6 +856,14 @@ const server = app.listen(config.port, config.host, () => {
@@ -837,6 +856,14 @@ const server = app.listen(config.port, config.host, () => {
// Account merges older than 30 days keep only the alias facts (ADR-029).
// Account merges older than 30 days keep only the alias facts (ADR-029).
const reduceMerges = () => { try { const n = require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };
const reduceMerges = () => { try { const n = require('./identity/account-merge').reduceExpired(db); if (n) console.log(`[AccountMerge] reduced ${n} merge record(s) past 30 days`); } catch (e) { console.warn('[AccountMerge] reduce failed:', e.message); } };
timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));
timers.push(setTimeout(reduceMerges, 6 * 60 * 1000), setInterval(reduceMerges, 24 * 60 * 60 * 1000));
+ // Exports past their deadline are built, archives past 7 days deleted, deletions past their 30-day grace carried out (ADR-033).
+ const sweepAccountData = () => {
+ try {
+ const n = require('./identity/account-data').sweep(db, { dir: ACCOUNT_EXPORT_DIR, notify: notifyAccountData });
+ if (n.built || n.expired || n.deleted) console.log(`[AccountData] sweep: ${JSON.stringify(n)}`);
+ } catch (e) { console.warn('[AccountData] sweep failed:', e.message); }
+ };
+ timers.push(setTimeout(sweepAccountData, 60 * 1000), setInterval(sweepAccountData, 2 * 60 * 1000));
// Clean expired sessions daily
// Clean expired sessions daily
timers.push(setInterval(() => {
timers.push(setInterval(() => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
+'use strict';
+/**
+ * A minimal zip archive (deflate, UTF-8 names, no zip64) for the account export (ADR-033): a handful of JSON files,
+ * well under 4 GB. build(entries) → Buffer; read(buffer) → { name: Buffer } (tests, and a check after writing).
+ */
+const zlib = require('zlib');
+
+function dos(d) {
+ return {
+ time: (d.getUTCHours() << 11) | (d.getUTCMinutes() << 5) | Math.floor(d.getUTCSeconds() / 2),
+ date: ((Math.max(1980, d.getUTCFullYear()) - 1980) << 9) | ((d.getUTCMonth() + 1) << 5) | d.getUTCDate(),
+ };
+}
+
+/** entries: [{ name, data: Buffer|string }] */
+function build(entries, { date = new Date() } = {}) {
+ const { time, date: day } = dos(date);
+ const locals = [];
+ const central = [];
+ let offset = 0;
+ for (const e of entries) {
+ const name = Buffer.from(e.name, 'utf8');
+ const raw = Buffer.isBuffer(e.data) ? e.data : Buffer.from(String(e.data), 'utf8');
+ const crc = zlib.crc32(raw);
+ const body = zlib.deflateRawSync(raw);
+ const h = Buffer.alloc(30);
+ h.writeUInt32LE(0x04034b50, 0);
+ h.writeUInt16LE(20, 4);
+ h.writeUInt16LE(0x0800, 6); // UTF-8 names
+ h.writeUInt16LE(8, 8); // deflate
+ h.writeUInt16LE(time, 10);
+ h.writeUInt16LE(day, 12);
+ h.writeUInt32LE(crc, 14);
+ h.writeUInt32LE(body.length, 18);
+ h.writeUInt32LE(raw.length, 22);
+ h.writeUInt16LE(name.length, 26);
+ locals.push(h, name, body);
+ const c = Buffer.alloc(46);
+ c.writeUInt32LE(0x02014b50, 0);
+ c.writeUInt16LE(20, 4);
+ c.writeUInt16LE(20, 6);
+ c.writeUInt16LE(0x0800, 8);
+ c.writeUInt16LE(8, 10);
+ c.writeUInt16LE(time, 12);
+ c.writeUInt16LE(day, 14);
+ c.writeUInt32LE(crc, 16);
+ c.writeUInt32LE(body.length, 20);
+ c.writeUInt32LE(raw.length, 24);
+ c.writeUInt16LE(name.length, 28);
+ c.writeUInt32LE(offset, 42);
+ central.push(c, name);
+ offset += 30 + name.length + body.length;
+ }
+ const dir = Buffer.concat(central);
+ const end = Buffer.alloc(22);
+ end.writeUInt32LE(0x06054b50, 0);
+ end.writeUInt16LE(entries.length, 8);
+ end.writeUInt16LE(entries.length, 10);
+ end.writeUInt32LE(dir.length, 12);
+ end.writeUInt32LE(offset, 16);
+ return Buffer.concat([...locals, dir, end]);
+}
+
+function read(buf) {
+ const endAt = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
+ if (endAt < 0) throw new Error('zip: no end record');
+ const count = buf.readUInt16LE(endAt + 10);
+ let p = buf.readUInt32LE(endAt + 16);
+ const out = {};
+ for (let i = 0; i < count; i++) {
+ if (buf.readUInt32LE(p) !== 0x02014b50) throw new Error('zip: bad central entry');
+ const method = buf.readUInt16LE(p + 10);
+ const crc = buf.readUInt32LE(p + 16);
+ const size = buf.readUInt32LE(p + 20);
+ const nameLen = buf.readUInt16LE(p + 28);
+ const extra = buf.readUInt16LE(p + 30);
+ const comment = buf.readUInt16LE(p + 32);
+ const local = buf.readUInt32LE(p + 42);
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
+ const body = buf.subarray(start, start + size);
+ const data = method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body);
+ if (zlib.crc32(data) !== crc) throw new Error(`zip: crc mismatch in ${name}`);
+ out[name] = data;
+ p += 46 + nameLen + extra + comment;
+ }
+ return out;
+}
+
+module.exports = { build, read };
new file mode 100644
new file mode 100644
@@ -0,0 +1,255 @@
@@ -0,0 +1,255 @@
+'use strict';
+// Account export and deletion (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). The ADR's acceptance tests:
+// - export: a job gathers every expected service's part (the holders of network.account.export.contribute), the zip
+// holds only the person's data and no secrets, a missing service makes it partial and says which, another person
+// cannot download it, one a day, and it expires after 7 days;
+// - deletion: refused without a fresh sign-in or the typed username, cancellable, nothing before the date;
+// - at the date Network's rows are erased, the username is released, the subject resolves as deleted, old tokens
+// are refused, and network.account.deleted (with the merged-in aliases) is queued once;
+// - services confirm with counts; staff (staff.users.manage) see what is outstanding and cancel with a reason.
+// node test/account-data.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const cookieParser = require('cookie-parser');
+const jwt = require('jsonwebtoken');
+const bcrypt = require('bcryptjs');
+const { validate, serviceAuth } = require('openvibe-contracts');
+const { initDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const wallet = require('../server/coins/wallet');
+const accountMerge = require('../server/identity/account-merge');
+const accountData = require('../server/identity/account-data');
+const zip = require('../server/utils/zip');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-account-data-'));
+const exportDir = path.join(dir, 'account-exports');
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+// The five services that keep data about people are registered clients (production has them all).
+for (const c of ['live', 'chat', 'community', 'media', 'games', 'tools']) db.prepare("INSERT OR IGNORE INTO oauth_clients (client_id, client_secret, name, redirect_uris, is_first_party) VALUES (?, 'x', ?, '[]', 1)").run(c, c);
+const principals = require('../server/identity/principals');
+principals.ensureSchema(db);
+require('../server/identity/follows').ensureSchema(db);
+accountMerge.ensureSchema(db);
+accountData.ensureSchema(db);
+const ISSUER = 'https://openvibe.network';
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
+const config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
+process.env.OWNER_USERNAME = 'boss';
+
+const hash = bcrypt.hashSync('secret12', 4);
+const mk = (name, role = 'user') => {
+ const id = db.prepare('INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)').run(name, `${name}@example.com`, hash, role).lastInsertRowid;
+ subjects.ensureUserSubject(db, db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+ return db.prepare('SELECT * FROM users WHERE id = ?').get(id);
+};
+const tok = (u, { authAgo = 10 } = {}) => jwt.sign({ sub: u.id, id: u.id, subject_id: u.subject_id, username: u.username, role: u.role || 'user',
+ ...(authAgo == null ? {} : { auth_time: Math.floor(Date.now() / 1000) - authAgo }), iat: Math.floor(Date.now() / 1000) - 10 }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+// A service token as Network issues it: `cap` holds only what principal_grants gives that service.
+const svc = (name, cap = ['network.account.export.contribute', 'network.account.deletion.confirm']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${name}`, actor_type: 'service', aud: ['openvibe.network'],
+ cap, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}` }, keys.privateKey);
+const events = (type) => db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope)).filter((e) => e.event_type === type);
+
+const authRoutes = require('../server/auth/routes');
+const requireAuth = require('../server/auth/session').makeRequireAuth(() => ({ db, publicKey: keys.publicKey, config }), authRoutes.signToken);
+const notified = [];
+const app = express();
+Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+app.use(cookieParser());
+const routers = accountData.routers({
+ requireAuth, staffClaims: require('../server/auth/staff-claims').staffClaims, dir: exportDir, notify: (u, n) => notified.push([u, n.title]),
+ contributeGuard: principals.guard('network.account.export.contribute', { legacy: false }), confirmGuard: principals.guard('network.account.deletion.confirm', { legacy: false }),
+});
+app.use('/api/v1/account', routers.me);
+app.use('/internal', routers.internal);
+app.use('/api/admin/account-deletions', routers.admin);
+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 call = (method, p, token, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, headers: r.headers, buf: Buffer.from(await r.arrayBuffer()) }))
+ .then((r) => { try { r.body = JSON.parse(r.buf.toString('utf8')); } catch { r.body = {}; } return r; });
+ const settle = () => new Promise((r) => setImmediate(r));
+ try {
+ const dana = mk('dana'); const eve = mk('eve'); const x = mk('xavier'); const boss = mk('boss', 'admin');
+ db.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id) VALUES (?, 'google', 'g-dana')").run(dana.id);
+ db.prepare("INSERT INTO user_sessions (user_id, session_token, device_name, is_active, expires_at) VALUES (?, 'sess-secret-dana', 'phone', 1, datetime('now', '+1 day'))").run(dana.id);
+ db.prepare("INSERT INTO oauth_tokens (token, client_id, user_id, scope, expires_at) VALUES ('rt-secret-dana', 'tools', ?, 'openid', datetime('now', '+1 day'))").run(dana.id);
+ wallet.credit(db, { user_id: dana.id, app_id: 'live', amount: 40, reason: 'chat', idempotency_key: 'd-1' });
+ require('../server/identity/modules').write(db, dana.subject_id, 'live.profile', { is_streamer: true, followers: 2 }, { writer: { type: 'service', id: 'live' } });
+ const follows = require('../server/identity/follows');
+ follows.setFollow(db, dana.subject_id, 'channel', x.subject_id, true, { emit: false });
+ follows.setFollow(db, x.subject_id, 'channel', dana.subject_id, true, { emit: false });
+ db.prepare("INSERT INTO user_preferences (user_id, language) VALUES (?, 'en')").run(dana.id);
+
+ // ── Export ──
+ const expected = accountData.expectedServices(db, 'network.account.export.contribute');
+ assert.deepStrictEqual(expected, ['chat', 'community', 'games', 'live', 'media'], 'the holders of the contribute grant');
+ let r = await call('POST', '/api/v1/account/export', tok(dana));
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.ok(validate('network.account-export@1', r.body).valid, JSON.stringify(validate('network.account-export@1', r.body).errors));
+ const exp = r.body.export_id;
+ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), expected.map((s) => `${s}:waiting`));
+ const req = events('network.account.export_requested');
+ assert.strictEqual(req.length, 1);
+ assert.deepStrictEqual([req[0].payload.export_id, req[0].payload.subject, req[0].visibility], [exp, dana.subject_id, 'internal']);
+ r = await call('POST', '/api/v1/account/export', tok(dana));
+ assert.deepStrictEqual([r.status, r.body.export_id], [200, exp], 'one open export at a time');
+
+ const part = (files, subject = dana.subject_id) => ({ subject, files });
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('live'), part([{ name: 'follows.json', content: [{ channel: 'xavier' }] }, { name: 'profile.json', content: { bio: 'hi' } }]));
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.ok(validate('network.account-data-receipt@1', r.body).valid);
+ assert.deepStrictEqual([r.body.service, r.body.replaced], ['live', false]);
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('live'), part([{ name: 'follows.json', content: [{ channel: 'xavier' }, { channel: 'yolanda' }] }]));
+ assert.strictEqual(r.body.replaced, true, 'a second part replaces the first');
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));
+ assert.deepStrictEqual([r.status, r.body.error], [400, 'export.wrong_subject']);
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: '../network/account.json', content: {} }]));
+ assert.deepStrictEqual([r.status, r.body.error], [400, 'export.invalid_part']);
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('tools', ['network.modules.read']), part([{ name: 'usage.json', content: [] }]));
+ assert.strictEqual(r.status, 403, 'a service without the grant is refused');
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, tok(dana), part([{ name: 'x.json', content: [] }]));
+ assert.strictEqual(r.status, 401, 'a person token is not a service token');
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('chat'), part([{ name: 'messages.json', content: [{ message: 'hello' }] }]));
+ assert.strictEqual(r.status, 200);
+ await settle();
+ assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp).status, 'pending', 'still waiting for three services');
+ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
+ assert.deepStrictEqual([r.status, r.body.error], [409, 'export.pending']);
+
+ // The deadline passes: the archive is built without the silent services, and says so.
+ const deadline = Date.parse(db.prepare('SELECT deadline FROM account_exports WHERE id = ?').get(exp).deadline);
+ assert.deepStrictEqual(accountData.sweep(db, { dir: exportDir, now: deadline + 1000, notify: (u, n) => notified.push([u, n.title]) }), { built: 1, expired: 0, deleted: 0 });
+ r = await call('GET', `/api/v1/account/export/${exp}`, tok(dana));
+ assert.strictEqual(r.body.status, 'partial');
+ assert.ok(validate('network.account-export@1', r.body).valid);
+ assert.deepStrictEqual(r.body.services.map((s) => `${s.service}:${s.status}`), ['network:received', 'chat:received', 'live:received', 'community:missing', 'games:missing', 'media:missing']);
+ assert.deepStrictEqual(notified.map((n) => n[0]), [dana.id], 'the person is told');
+ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('content-type'), 'application/zip');
+ assert.match(r.headers.get('content-disposition'), /attachment; filename="openvibe-dana-\d{4}-\d\d-\d\d\.zip"/);
+ const files = zip.read(r.buf);
+ assert.ok(files['README.txt'].toString().includes('community: MISSING'));
+ assert.deepStrictEqual(JSON.parse(files['live/follows.json']).map((f) => f.channel), ['xavier', 'yolanda'], 'the replacing part');
+ const account = JSON.parse(files['network/account.json']);
+ assert.deepStrictEqual([account.username, account.email, account.subject_id], ['dana', '[email protected]', dana.subject_id]);
+ const all = Object.values(files).map((b) => b.toString()).join('\n');
+ for (const secret of ['password_hash', 'sess-secret-dana', 'rt-secret-dana', hash]) assert.ok(!all.includes(secret), `no ${secret} in the archive`);
+ assert.strictEqual(JSON.parse(files['network/opencoins.json']).balance, 40);
+ assert.deepStrictEqual(JSON.parse(files['network/follows.json']).followers_count, 1, 'followers as a count, never their names');
+ assert.ok(!all.includes('[email protected]'), "nobody else's data");
+ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(eve));
+ assert.strictEqual(r.status, 404, 'another person cannot download it');
+ r = await call('POST', `/internal/account-exports/${exp}/parts`, svc('media'), part([{ name: 'objects.json', content: [] }]));
+ assert.deepStrictEqual([r.status, r.body.error], [409, 'export.closed'], 'too late');
+ r = await call('POST', '/api/v1/account/export', tok(dana));
+ assert.deepStrictEqual([r.status, r.body.error], [429, 'export.too_soon'], 'one a day');
+ assert.ok(Number(r.headers.get('retry-after')) > 0);
+
+ // Every expected service answers: ready at once.
+ for (const s of ['community', 'games', 'media']) db.prepare("UPDATE principal_grants SET revoked_at = CURRENT_TIMESTAMP WHERE client_id = ? AND capability = 'network.account.export.contribute'").run(s);
+ r = await call('POST', '/api/v1/account/export', tok(eve));
+ const exp2 = r.body.export_id;
+ await call('POST', `/internal/account-exports/${exp2}/parts`, svc('live'), part([{ name: 'profile.json', content: {} }], eve.subject_id));
+ await call('POST', `/internal/account-exports/${exp2}/parts`, svc('chat'), part([{ name: 'messages.json', content: [] }], eve.subject_id));
+ await settle(); await settle();
+ assert.strictEqual(db.prepare('SELECT status FROM account_exports WHERE id = ?').get(exp2).status, 'ready');
+
+ // After 7 days the archive is gone.
+ const expires = Date.parse(db.prepare('SELECT expires_at FROM account_exports WHERE id = ?').get(exp).expires_at);
+ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: expires + 1000 }).expired, 2);
+ assert.ok(!fs.existsSync(path.join(exportDir, `${exp}.zip`)));
+ r = await call('GET', `/api/v1/account/export/${exp}/download`, tok(dana));
+ assert.deepStrictEqual([r.status, r.body.error], [410, 'export.expired']);
+
+ // ── Deletion ──
+ r = await call('POST', '/api/v1/account/deletion', tok(dana, { authAgo: 11 * 60 }), { confirm_username: 'dana' });
+ assert.deepStrictEqual([r.status, r.body.error], [401, 'deletion.sign_in_again']);
+ r = await call('POST', '/api/v1/account/deletion', tok(dana, { authAgo: null }), { confirm_username: 'dana' });
+ assert.deepStrictEqual([r.status, r.body.error], [401, 'deletion.sign_in_again'], 'a renewal is not a sign-in');
+ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dan' });
+ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.confirm_username']);
+ r = await call('POST', '/api/v1/account/deletion', tok(boss), { confirm_username: 'boss' });
+ assert.deepStrictEqual([r.status, r.body.error], [403, 'deletion.owner']);
+ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'DANA' });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.ok(validate('network.account-deletion@1', r.body).valid);
+ assert.strictEqual(Date.parse(r.body.delete_after) - Date.parse(r.body.requested_at), 30 * 86400000);
+ r = await call('DELETE', '/api/v1/account/deletion', tok(dana));
+ assert.strictEqual(r.body.status, 'cancelled', 'cancelled');
+ r = await call('GET', '/api/v1/account/deletion', tok(dana));
+ assert.strictEqual(r.body.deletion, null);
+ r = await call('POST', '/api/v1/account/deletion', tok(dana), { confirm_username: 'dana' });
+ const del = r.body.deletion_id;
+ const due = Date.parse(r.body.delete_after);
+ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due - 60000 }).deleted, 0, 'nothing before the date');
+ assert.strictEqual(db.prepare('SELECT username FROM users WHERE id = ?').get(dana.id).username, 'dana');
+
+ // dana had folded an older account in (ADR-029): its subject goes with her.
+ const old = mk('dana_old');
+ accountMerge.merge(db, old, db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
+ const oldToken = tok(dana);
+ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 1000 }).deleted, 1);
+ const gone = db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id);
+ assert.match(gone.username, /^deleted-/);
+ assert.deepStrictEqual([gone.email, gone.display_name, gone.password_hash, !!gone.deleted_at], [null, null, '!deleted', true]);
+ assert.match(db.prepare('SELECT username FROM users WHERE id = ?').get(old.id).username, /^deleted-/, 'the merged-in account too');
+ for (const [t, col] of [['linked_accounts', 'user_id'], ['user_sessions', 'user_id'], ['oauth_tokens', 'user_id'], ['user_preferences', 'user_id']]) {
+ assert.strictEqual(db.prepare(`SELECT COUNT(*) AS n FROM ${t} WHERE ${col} = ?`).get(dana.id).n, 0, `${t} erased`);
+ }
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_modules WHERE subject_id = ?').get(dana.subject_id).n, 0);
+ assert.strictEqual(db.prepare('SELECT COUNT(*) AS n FROM user_follows WHERE follower_subject = ? OR target_id = ?').get(dana.subject_id, dana.subject_id).n, 0, 'follows both ways');
+ assert.strictEqual(wallet.getBalance ? wallet.getBalance(db, dana.id) : db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(dana.id).balance, 0);
+ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM coin_transactions WHERE user_id = ? AND reason = 'account_deleted'").get(dana.id).n, 1, 'one closing entry; the ledger stays');
+ assert.ok(mk('dana'), 'the username is released');
+ const p = subjects.resolve(db, { subject_id: dana.subject_id });
+ assert.deepStrictEqual([p.deleted, p.display_name], [true, 'Deleted account']);
+ assert.strictEqual(subjects.resolve(db, { subject_id: old.subject_id }).deleted, true, 'the alias resolves as deleted too');
+ r = await call('GET', '/api/v1/account/deletion', oldToken);
+ assert.strictEqual(r.status, 401, 'old tokens are refused');
+ const ev = events('network.account.deleted');
+ assert.strictEqual(ev.length, 1);
+ assert.ok(validate('network.account.deleted@1', ev[0].payload).valid);
+ assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
+ assert.ok(events('network.user.token_valid_after').some((e) => e.payload.reason === 'account_deleted'));
+ assert.strictEqual(accountData.sweep(db, { dir: exportDir, now: due + 5000 }).deleted, 0, 'once');
+
+ // Services confirm; staff see what is outstanding.
+ const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
+ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('chat'), confirm);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual([r.body.id, r.body.service, r.body.replaced], [del, 'chat', false]);
+ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('chat'), confirm);
+ assert.strictEqual(r.body.replaced, true);
+ r = await call('POST', `/internal/account-deletions/${del}/confirmations`, svc('live'), { ...confirm, subject: eve.subject_id });
+ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.wrong_subject']);
+ r = await call('GET', '/api/admin/account-deletions', tok(eve));
+ assert.strictEqual(r.status, 403);
+ r = await call('GET', '/api/admin/account-deletions', tok(boss));
+ const row = r.body.deletions.find((d) => d.deletion_id === del);
+ assert.deepStrictEqual([row.confirmed, row.outstanding], [['chat'], ['community', 'games', 'live', 'media']]);
+
+ // Staff cancel a scheduled deletion only with a reason.
+ await call('POST', '/api/v1/account/deletion', tok(eve), { confirm_username: 'eve' });
+ const eveDel = db.prepare("SELECT id FROM account_deletions WHERE user_id = ? AND status = 'scheduled'").get(eve.id).id;
+ r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'no' });
+ assert.deepStrictEqual([r.status, r.body.error], [400, 'deletion.reason_required']);
+ r = await call('POST', `/api/admin/account-deletions/${eveDel}/cancel`, tok(boss), { reason: 'the person wrote to support and changed their mind' });
+ assert.strictEqual(r.body.status, 'cancelled');
+ assert.ok(db.prepare("SELECT 1 FROM audit_log WHERE action = 'account_deletion_cancelled'").get());
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('account export and deletion: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -53,7 +53,7 @@ const server = http.createServer(app);
@@ -53,7 +53,7 @@ const server = http.createServer(app);
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
const claims = serviceAuth.verifyServiceToken(t.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(claims.ok, claims.reason);
assert.ok(claims.ok, claims.reason);
assert.strictEqual(claims.claims.sub, 'svc:live');
assert.strictEqual(claims.claims.sub, 'svc:live');
- assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
+ assert.deepStrictEqual(claims.claims.cap, ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.analytics.creator.read', 'network.coins.credit', 'network.coins.debit', 'network.follows.read', 'network.follows.write', 'network.modules.read', 'network.modules.write', 'network.notifications.push']);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(validate('identity.service-token-claims@1', claims.claims).valid);
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
assert.ok(claims.claims.exp - claims.claims.iat <= 300, 'short-lived');
const full = t.body.access_token;
const full = t.body.access_token;
@@ -90,7 +90,7 @@ const server = http.createServer(app);
@@ -90,7 +90,7 @@ const server = http.createServer(app);
assert.deepStrictEqual(holders, ['community', 'media', 'openre']);
assert.deepStrictEqual(holders, ['community', 'media', 'openre']);
t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });
t = await token({ client_id: 'media', client_secret: 'media-secret', audience: 'openvibe.network' });
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
assert.strictEqual(t.status, 200, JSON.stringify(t.body));
- assert.strictEqual(t.body.scope, 'identity.subject.resolve', 'Media resolves object owners to subjects, and nothing else here');
+ assert.strictEqual(t.body.scope, 'identity.subject.resolve network.account.deletion.confirm network.account.export.contribute', 'Media resolves object owners to subjects and takes part in account export and deletion (ADR-033), nothing else here');
const mediaResolve = t.body.access_token;
const mediaResolve = t.body.access_token;
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });
t = await token({ client_id: 'live', client_secret: 'live-secret', audience: 'openvibe.media' });
assert.strictEqual(t.status, 400, 'no grants for that audience');
assert.strictEqual(t.status, 400, 'no grants for that audience');
@@ -129,7 +129,7 @@ const server = http.createServer(app);
@@ -129,7 +129,7 @@ const server = http.createServer(app);
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
db.prepare("UPDATE oauth_clients SET client_secret = 'community-secret' WHERE client_id = 'community'").run();
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
const com = await token({ client_id: 'community', client_secret: 'community-secret', audience: 'openvibe.network' });
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
assert.strictEqual(com.status, 200, JSON.stringify(com.body));
- assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, platform blocks, plus its community.profile module');
+ assert.deepStrictEqual(com.body.scope.split(' '), ['identity.subject.resolve', 'network.account.deletion.confirm', 'network.account.export.contribute', 'network.blocks.read', 'network.modules.read', 'network.modules.write'], 'resolve, account export and deletion, platform blocks, plus its community.profile module');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${com.body.access_token}` });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.results['7'].username, 'payee');
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });
r = await post('/internal/identity/resolve-batch', { system: 'network', ids: ['7'] }, { authorization: `Bearer ${creditOnly}` });