openvibers/openvibe.wiki Commits

Wiki: account export and deletion (ADR-033) — a person's own spaces deleted as their owner would, roles, watches and drafts removed, contributions made authorless (append-only rows only inside the erasure transaction), reviews kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#18)

OP OpenVibers committed 9 Oct 2026 · 62b4f081df · 14 files, +355 −16

Changes

ddiff --git a/.env.example b/.env.example
iindex 33e1536..0e28a68 100644
---- a/.env.example
++++ b/.env.example
@@@ -44,6 +44,9 @@ OV_OAUTH_CLIENT_SECRET=
# OpenVibe.Events — the outbox relay runs only when EVENTS_URL is set (events wait in the outbox until then).
#EVENTS_URL=http://127.0.0.1:4300
++# OpenVibe.Events → Wiki (ADR-033 account export and deletion): signs a delivery to POST /internal/events
++# (comma-separated for rotation, 32+ characters each). Unset: the route answers 503 and no subscription is created.
++#WIKI_EVENTS_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
# OpenVibe.Community — discussion threads (community.comment.write; community.comment.moderate
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 32bf490..9aa7b12 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,11 @@
## Unreleased
++- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `WIKI_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/wiki/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).
++ - **Deleted:** the person's own user spaces (through `deleteSpace`), roles, watches and drafts.
++ - **Authorless:** their contributions elsewhere stay without their id.
++ - **Kept:** reviews.
++ - Migration `0002_account_erasure.sql` adds `account_data_events` and lets only the erasure transaction clear the author on the append-only revisions and citations. The two subscriptions are created at boot. `test/account-data.test.js`.
- Pin refresh: `openvibe-contracts` v0.96.0 → v0.107.0, `openvibe-shared` v2.9.0 → v2.11.0 and `openvibe-sdk` v0.26.0 → v0.34.0 (`openvibe-publishing` stays v1.3.0, already the newest tag). The releases are additive — Contracts adds capabilities and event schemas for other services, Shared adds showcase next-step cards and a Bot icon, SDK adds Fabric/usage/bot subpaths and a PGlite-backed `createTestDb` — with no change to the shell a page renders through or to any contract this service validates, so no route, page or payload changed.
- `openvibe-contracts` v0.76.0 → v0.96.0: the wiki capabilities, the wiki service manifest and the `wiki.*` event payload schemas are released in Contracts, so the service-token guard resolves `wiki.*` through the library (the local proposal fallback stays for a future id) and `openvibe-contracts-check --service wiki` passes. Every event the service emits validates against its `wiki.*` payload schema (`test/event-contracts.test.js`). No route, body or payload changed.
- `openvibe-publishing` v1.2.0 → v1.3.0: `/llms-full.txt` now publishes the `llms.txt` header plus an excerpt of every indexable page (the same gate as the sitemap, never a whole article) under a 512 KiB cap, and the home page carries the site's one-line `ai-summary` meta and WebPage JSON-LD.
ddiff --git a/README.md b/README.md
iindex 03f6489..9bfff33 100644
---- a/README.md
++++ b/README.md
@@@ -282,13 +282,33 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
v0.107.0, `openvibe-shared` v2.15.0
-- (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.35.0 (db, auth, PostgreSQL
-- events outbox, per-actor limits, testing) — pinned release tarballs.
++ (Frame, release, metrics, readiness, SEO helpers, legal pages), `openvibe-sdk` v0.37.0 (db, auth, PostgreSQL
++ events outbox, per-actor limits, account export and deletion, testing) — pinned release tarballs.
- OpenVibe.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
the index events). All but the Network key are optional at runtime and degrade to explicit failure
states.
- OpenVibe.AI for proposals (not wired to Wiki yet: the proposal API is the seam).
++- Account export and deletion (ADR-033): Network grants `events.subscription.manage` (openvibe.events) for the two
++ subscriptions created at boot, then, last and once the release is live, `network.account.export.contribute` and
++ `network.account.deletion.confirm` (openvibe.network).
++
++### Account export and deletion (ADR-033)
++
++`network.account.export_requested` and `network.account.deleted` arrive at `POST /internal/events`. The route is
++loopback-only (nginx answers 404 for `/internal/`, and the handler refuses a forwarded request) and signed with
++`WIKI_EVENTS_SECRET`. They are answered by `server/wiki/account-data.js` over `openvibe-sdk/account-data`, with one
++receipt per export and deletion in `account_data_events`.
++
++- **Export:** the person's spaces, pages, revisions, AI proposals, roles, watches, drafts and reviews.
++- **Deleted:** a user space they own is deleted the way its owner would delete it (`deleteSpace` as the system actor:
++ unpublished, out of Search and the sitemap, answering 410). Their roles, watches, drafts and wiki.projects state go
++ too.
++- **Authorless:** what they wrote in other spaces stays, with their id removed (`created_by`, `proposed_by` and
++ `attached_by` become `deleted`). Revisions and citations are append-only, so only the erasure transaction may clear
++ a revision's author (and their id in `meta.authorship.authors`) or a citation's `attached_by`
++ (`wiki.account_erasure`, migration `0002_account_erasure.sql`); the text and sources never change.
++- **Kept:** page reviews, since a person's approval is what lets reviewed text stay published.
## Capabilities
ddiff --git a/STATUS.json b/STATUS.json
iindex eb9051c..8494db4 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,12 +8,12 @@
"port": 4800,
"domain": "openvibe.wiki",
"contracts": "openvibe-contracts v0.112.0",
-- "sdk": "openvibe-sdk v0.35.0",
++ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-shared v2.15.0",
-- "openvibe-sdk v0.35.0"
++ "openvibe-sdk v0.37.0"
],
"capabilities": [
"wiki.space.create",
ddiff --git a/deploy/nginx/openvibe.wiki.conf b/deploy/nginx/openvibe.wiki.conf
iindex a45e7b0..c75fa29 100644
---- a/deploy/nginx/openvibe.wiki.conf
++++ b/deploy/nginx/openvibe.wiki.conf
@@@ -118,6 +118,10 @@ server {
proxy_pass http://127.0.0.1:4800;
}
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
++ # Events posts to http://127.0.0.1:4800/internal/events directly; never proxied from outside (ADR-033).
++ location /internal/ { return 404; }
++
location / {
limit_req zone=ovwiki_write burst=20 nodelay;
proxy_pass http://127.0.0.1:4800;
ddiff --git a/migrations/0002_account_erasure.sql b/migrations/0002_account_erasure.sql
nnew file mode 100644
iindex 0000000..35d47a1
---- /dev/null
++++ b/migrations/0002_account_erasure.sql
@@@ -0,0 +1,65 @@
++-- phase: expand
++-- ADR-033 account deletion (server/wiki/account-data.js) and the account-data receipts.
++--
++-- account_data_events: the receipts of the account export and deletion deliveries this service applied
++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing. A row holds
++-- an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++--
++-- Page revisions and citations stay append-only, with one exception. Inside the account-erasure transaction, and
++-- only there (it sets wiki.account_erasure = 'on' with set_config(..., true), so the setting ends with the
++-- transaction), the person's id may leave a row: a revision's author becomes NULL and their id leaves
++-- meta.authorship.authors (also on a revision someone else wrote with them), and a citation's attached_by becomes
++-- NULL. Every other column must stay as it was, so the text, the sources and their provenance never change. Deletes
++-- keep their old rules (only after a recorded purge).
++
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome JSONB,
++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
++ sent_at TIMESTAMPTZ
++);
++
++CREATE OR REPLACE FUNCTION wiki_page_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ IF current_setting('wiki.account_erasure', true) = 'on'
++ AND (NEW.author IS NULL OR NEW.author IS NOT DISTINCT FROM OLD.author)
++ AND (NEW.id, NEW.entity_id, NEW.number, NEW.parent_id, NEW.parent_number, NEW.kind, NEW.reverted_to, NEW.content,
++ NEW.fields, NEW.content_hash, NEW.message, NEW.created_at)
++ IS NOT DISTINCT FROM
++ (OLD.id, OLD.entity_id, OLD.number, OLD.parent_id, OLD.parent_number, OLD.kind, OLD.reverted_to, OLD.content,
++ OLD.fields, OLD.content_hash, OLD.message, OLD.created_at)
++ AND (NEW.meta - 'authorship') IS NOT DISTINCT FROM (OLD.meta - 'authorship')
++ AND ((NEW.meta->'authorship') - 'authors') IS NOT DISTINCT FROM ((OLD.meta->'authorship') - 'authors') THEN
++ RETURN NEW;
++ END IF;
++ RAISE EXCEPTION 'wiki_page_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM wiki_page_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'wiki_page_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++
++CREATE OR REPLACE FUNCTION wiki_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ IF current_setting('wiki.account_erasure', true) = 'on' AND NEW.attached_by IS NULL
++ AND (NEW.id, NEW.entity_id, NEW.revision, NEW.anchor, NEW.source_item_id, NEW.url, NEW.title, NEW.retrieved_at,
++ NEW.quote_text, NEW.quote_start, NEW.quote_end, NEW.license_note, NEW.carried_from, NEW.attached_at)
++ IS NOT DISTINCT FROM
++ (OLD.id, OLD.entity_id, OLD.revision, OLD.anchor, OLD.source_item_id, OLD.url, OLD.title, OLD.retrieved_at,
++ OLD.quote_text, OLD.quote_start, OLD.quote_end, OLD.license_note, OLD.carried_from, OLD.attached_at) THEN
++ RETURN NEW;
++ END IF;
++ RAISE EXCEPTION 'wiki_citations rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM wiki_citation_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'wiki_citations rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
ddiff --git a/package-lock.json b/package-lock.json
iindex 1f15248..6d07558 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
@@@ -893,9 +893,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.35.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
-- "integrity": "sha512-IF7XB97tU7Q+ccwssGx6QXXcfDPcVpl+mX3BjIr7C6NJlcmoqj9DbHiKrBxlVav0eG44Jf8zuXEwokrHDq3J0w==",
++ "version": "0.37.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
++ "integrity": "sha512-afojId2KzOL4ZZrpwIeIAYUaX24ZOC3Nt+bdd1HSXm7aislt8WQNS62i8/d8W3udLr6rmSG29SjmnaRHUGfd4Q==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 1afefa1..4e7903e 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.0",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.3.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.35.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
},
ddiff --git a/server/app.js b/server/app.js
iindex 47a6880..934fc60 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -23,7 +23,7 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
--function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null }) {
++function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valkey = null, log = console, rateLimits = true, fetchImpl = globalThis.fetch, limitsNow = null, indexnow = null, accountData = null, accountSend = null }) {
const app = express();
// Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
const viewers = { ...baseViewers, resolve: async (req, opts) => svc.loadRoles(await baseViewers.resolve(req, opts)) };
@@@ -63,6 +63,15 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
});
app.use(cookieParser());
++ // ── OpenVibe.Events → Wiki (loopback only: nginx answers 404 for /internal/) ──
++ // network.account.export_requested and network.account.deleted (ADR-033), answered by openvibe-sdk/account-data's
++ // consumer over wiki/account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a request
++ // that came through a proxy.
++ if (accountData) {
++ const send = accountSend || (async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Wiki cannot answer account events'); });
++ app.post('/internal/events', accountData.consumer({ secrets: config.eventsSecrets || [], send, log }));
++ }
++
const limiter = (windowMs, max) => (rateLimits ? rateLimit({ windowMs, max, standardHeaders: true, legacyHeaders: false }) : (_q, _s, n) => n());
app.use('/auth/', limiter(15 * 60000, 60));
app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
ddiff --git a/server/config.js b/server/config.js
iindex 0575637..c3cf866 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -56,6 +56,10 @@ function load(env = process.env) {
// Other services (all optional: each integration degrades to an explicit failure state).
eventsUrl: trim(env.EVENTS_URL || ''),
eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // OpenVibe.Events → Wiki (ADR-033 account export and deletion, server/wiki/account-data.js): the secret(s) that
++ // sign a delivery to POST /internal/events (comma-separated for rotation, 32+ characters each). Unset: the route
++ // answers 503 and no subscription is created at boot.
++ eventsSecrets: String(env.WIKI_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
communityUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
communityInternalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || ''),
sourcesInternalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
ddiff --git a/server/index.js b/server/index.js
iindex b3aa080..3b3949c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -17,6 +17,8 @@ const { createWikiService } = require('./wiki/service');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
++const accountDataLib = require('./wiki/account-data');
++const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service, plan T1): the job timers and the Events outbox relay stop taking new
@@@ -25,12 +27,13 @@ const { createApp } = require('./app');
* test handed in its own database (start's `givenDb`), which stays open. `exit` and `signals` are injectable
* so a test can watch the stop and no test process installs signal handlers.
*/
--function createLifecycle({ server, db, valkey = null, platform, timers = [], closeDb = true, exit, signals, log } = {}) {
++function createLifecycle({ server, db, valkey = null, platform, timers = [], closeDb = true, exit, signals, log, extra = [] } = {}) {
return gracefulStop({
name: 'Wiki', server, log, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 0, exit, signals,
stop: [
() => timers.forEach(clearInterval),
() => platform.outbox.stop(),
++ ...extra,
],
close: [
() => { if (closeDb) return db.close(); },
@@@ -39,7 +42,7 @@ function createLifecycle({ server, db, valkey = null, platform, timers = [], clo
});
}
--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null, indexnow: givenIndexNow = null, signals = false, exit = () => {} } = {}) {
++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, tokens = null, publicKey = null, log = console, listen = true, workers = listen, rateLimits = true, limitsNow = null, indexnow: givenIndexNow = null, signals = false, exit = () => {}, accountSend: givenSend = null } = {}) {
config = config || load();
// PostgreSQL (ADR-035): migrate on the owner's direct connection, then serve on the pooled runtime role.
// Tests hand in a migrated database of their own (test/db-helper.js).
@@@ -70,7 +73,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
-- const app = createApp({ config, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow });
++ // Account export and deletion (ADR-033): the table map over the service, and the sender to Network's internal routes
++ // with Wiki's own client-credentials token (a test hands in a stand-in).
++ const accountData = accountDataLib.create({ db, svc, log });
++ const accountSend = givenSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : null);
++ const app = createApp({ config, svc, viewers, platform, keys, db, valkey, log, rateLimits, fetchImpl, limitsNow, indexnow, accountData, accountSend });
const timers = [];
if (workers) {
@@@ -95,13 +104,20 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
}
let server = null;
++ let subscriptions = null;
if (listen) {
await new Promise((resolve) => { server = app.listen(config.port, config.host, resolve); });
server.keepAliveTimeout = 65000;
log.log(`[Wiki] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
++ // The two account subscriptions at OpenVibe.Events, created when missing; off without EVENTS_URL,
++ // WIKI_EVENTS_SECRET or the client secret.
++ subscriptions = startSubscriptions({
++ eventsUrl: config.eventsUrl, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: (config.eventsSecrets || [])[0],
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl, log,
++ });
}
-- const lifecycle = createLifecycle({ server, db, valkey, platform, timers, closeDb: !givenDb, signals, exit, log });
++ const lifecycle = createLifecycle({ server, db, valkey, platform, timers, closeDb: !givenDb, signals, exit, log, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
async function stop() {
return lifecycle.stop('stop');
ddiff --git a/server/wiki/account-data.js b/server/wiki/account-data.js
nnew file mode 100644
iindex 0000000..ee543ee
---- /dev/null
++++ b/server/wiki/account-data.js
@@@ -0,0 +1,83 @@
++'use strict';
++
++/**
++ * Account export and deletion → Wiki (ADR-033; openvibe-sdk/account-data). What Wiki holds about a person:
++ *
++ * their own, deleted drafts, page watches, the roles they hold in spaces, and the wiki.projects module state
++ * their user spaces a space they own (kind 'user') is deleted the way its owner would delete it
++ * (service.deleteSpace as the system actor: unpublished, its pages leave Search and the
++ * sitemap), and its owner and creator become 'deleted'
++ * contributions elsewhere stay, made authorless: pages (created_by), AI proposals (proposed_by), attachments
++ * (attached_by) become 'deleted'; reviewed_by, granted_by, media_owner and purged_by become
++ * NULL; revisions and citations are append-only, so only inside the erasure transaction
++ * (wiki.account_erasure, migrations/0002_account_erasure.sql) may a revision's author become
++ * NULL (and their id leave meta.authorship.authors) or a citation's attached_by become NULL
++ * kept page reviews: a person's approval is what lets reviewed text stay published
++ * (openvibe-publishing/authorship), counted as retained
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const SYSTEM = Object.freeze({ kind: 'system', service: 'svc:wiki', subject: null });
++const anonymize = { anonymize: {} };
++
++const TABLES = [
++ { table: 'wiki_page_drafts', subject: 'owner', file: 'drafts.json', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },
++ { table: 'wiki_watchers', subject: 'subject', file: 'watching.json', columns: ['page_id', 'created_at'] },
++ { table: 'wiki_permissions', subject: 'subject', file: 'roles.json', columns: ['space_id', 'role', 'granted_at'], order: 'granted_at' },
++ { table: 'wiki_permissions', subject: 'granted_by', file: null, erase: anonymize },
++ { table: 'wiki_ai_proposals', subject: 'reviewed_by', file: null, erase: anonymize },
++ { table: 'wiki_attachment_origins', subject: 'media_owner', file: null, erase: anonymize },
++ { table: 'wiki_page_revision_purges', subject: 'purged_by', file: null, erase: anonymize },
++ { table: 'wiki_citation_purges', subject: 'purged_by', file: null, erase: anonymize },
++ { table: 'wiki_page_reviews', subject: 'reviewer', file: 'reviews.json', columns: ['entity_id', 'revision', 'decision', 'note', 'reviewed_at'], order: 'reviewed_at', erase: { keep: 'a person\'s approval is what lets reviewed text stay published' } },
++];
++
++async function extraExport(db, subject) {
++ const files = [];
++ const spaces = await db.many(`SELECT id, slug, name, description, kind, visibility, created_at, updated_at, deleted_at FROM wiki_spaces
++ WHERE owner = $1 OR created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (spaces.length) files.push({ name: 'spaces.json', content: spaces });
++ const pages = await db.many(`SELECT id, space_id, slug, title, state, visibility, created_at, updated_at FROM wiki_pages
++ WHERE created_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (pages.length) files.push({ name: 'pages.json', content: pages });
++ const revisions = await db.many(`SELECT entity_id, number, kind, content, fields, message, created_at FROM wiki_page_revisions
++ WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (revisions.length) files.push({ name: 'revisions.json', content: revisions });
++ const proposals = await db.many(`SELECT id, page_id, space_id, revision, status, note, created_at FROM wiki_ai_proposals
++ WHERE proposed_by = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (proposals.length) files.push({ name: 'ai-proposals.json', content: proposals });
++ return files;
++}
++
++/** The account-data handle over Wiki's database and service (service.deleteSpace for the person's own spaces). */
++function create({ db, svc, log = console } = {}) {
++ async function extraErase(t, subjects, counts) {
++ // Their own user spaces go the way their owner would delete them (a savepoint inside this transaction).
++ const spaces = await t.many("SELECT id FROM wiki_spaces WHERE kind = 'user' AND owner = ANY($1::text[]) AND deleted_at IS NULL", [subjects]);
++ for (const s of spaces) await svc.deleteSpace(s.id, SYSTEM);
++ counts.add(counts.erased, 'wiki_spaces', spaces.length);
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE wiki_spaces SET owner = 'deleted' WHERE owner = ANY($1::text[])", [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE wiki_spaces SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE wiki_pages SET created_by = 'deleted' WHERE created_by = ANY($1::text[])", [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE wiki_ai_proposals SET proposed_by = 'deleted' WHERE proposed_by = ANY($1::text[])", [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE wiki_attachment_origins SET attached_by = 'deleted' WHERE attached_by = ANY($1::text[])", [subjects]));
++ // Only this transaction may take a person's id out of the append-only rows; the setting ends with it.
++ await t.value("SELECT set_config('wiki.account_erasure', 'on', true)");
++ counts.add(counts.retained, 'tombstones', await t.exec('UPDATE wiki_page_revisions SET author = NULL WHERE author = ANY($1::text[])', [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec(`UPDATE wiki_page_revisions
++ SET meta = jsonb_set(meta, '{authorship,authors}', COALESCE((SELECT jsonb_agg(a) FROM jsonb_array_elements(meta->'authorship'->'authors') a
++ WHERE NOT ((a #>> '{}') = ANY($1::text[]))), '[]'::jsonb))
++ WHERE jsonb_typeof(meta->'authorship'->'authors') = 'array' AND (meta->'authorship'->'authors') ?| $1::text[]`, [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec('UPDATE wiki_citations SET attached_by = NULL WHERE attached_by = ANY($1::text[])', [subjects]));
++ await t.value("SELECT set_config('wiki.account_erasure', 'off', true)");
++ // Last: deleteSpace marked the owner for a wiki.projects push; nothing about them is pushed again.
++ counts.add(counts.erased, 'wiki_module_dirty', await t.exec('DELETE FROM wiki_module_dirty WHERE subject = ANY($1::text[])', [subjects]));
++ counts.add(counts.erased, 'wiki_module_pushes', await t.exec('DELETE FROM wiki_module_pushes WHERE subject = ANY($1::text[])', [subjects]));
++ }
++ return createAccountData({
++ db, service: 'wiki', tables: TABLES, extraExport, extraErase, log,
++ note: 'Your own user spaces are deleted. What you wrote in other spaces stays without your name; reviews you approved stay attributed.',
++ });
++}
++
++module.exports = { create, TABLES, TOPICS, SYSTEM };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..51f5e36
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,130 @@
++'use strict';
++/**
++ * ADR-033: Wiki's part of an account export and of an account deletion, through the signed loopback route
++ * POST /internal/events with a stand-in Network. Alice owns a user space with a published page; she also edits and
++ * watches a page in Bob's space, where she holds the editor role. Her export carries her spaces, pages, revisions and
++ * roles. Her deletion deletes her own space (as its owner would), removes her role, watch and drafts, leaves her
++ * revision in Bob's space without her id (the text unchanged), keeps reviews, and confirms once. The append-only rows
++ * refuse any other change, even inside the erasure.
++ */
++const assert = require('assert');
++const http = require('http');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const H = require('./helpers');
++
++const SECRET = `whsec_${'fixture'.repeat(6)}`;
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_wiki', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(` ✓ ${name}`); } catch (e) { failures++; console.log(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}`); }
++}
++
++(async () => {
++ const stub = await startNetworkStub();
++ const h = await H.boot({ env: { WIKI_EVENTS_SECRET: SECRET, OV_OAUTH_CLIENT_SECRET: 'wiki-secret' }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'wiki', clientSecret: 'wiki-secret' }) });
++ const alice = { kind: 'user', subject: H.subject() };
++ const bob = { kind: 'user', subject: H.subject() };
++ const db = h.db;
++ const count = async (sql, args) => Number(await db.value(sql, args));
++ const deliver = async (event, { secret = SECRET, headers = {} } = {}) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${h.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers } });
++ return { status: res.status, json: await res.json().catch(() => null) };
++ };
++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
++ let mine;
++ let theirs;
++ let bobsPage;
++
++ try {
++ await check('alice\'s own space and page, and her edit, role and watch in bob\'s space', async () => {
++ mine = await h.svc.createSpace({ name: 'Alice notes', slug: 'alice-notes' }, alice);
++ const { page } = await h.svc.createPage(mine.id, { title: 'Hello', body: `Mine ${H.LONG}`, citations: [{ url: 'https://example.org/a', title: 'A source', retrievedAt: '2026-09-01' }] }, alice);
++ await h.svc.publish(page.id, {}, alice);
++ theirs = await h.svc.createSpace({ name: 'Bob guide', slug: 'bob-guide' }, bob);
++ bobsPage = (await h.svc.createPage(theirs.id, { title: 'Guide', body: `Bob wrote this ${H.LONG}` }, bob)).page;
++ await h.svc.setRole(theirs.id, alice.subject, 'editor', bob);
++ await h.svc.loadRoles(alice);
++ alice.wikiRoles = null;
++ await h.svc.editPage(bobsPage.id, { expectedRevision: 1, title: 'Guide', body: `Bob wrote this, Alice improved it ${H.LONG}`, citations: [{ url: 'https://example.org/b', title: 'B source', retrievedAt: '2026-09-02' }] }, alice);
++ await h.svc.watch(bobsPage.id, alice, true);
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_page_revisions WHERE author = $1', [alice.subject]), 2);
++ });
++
++ await check('the export carries her spaces, pages, revisions, roles and watches, and nothing of bob\'s', async () => {
++ const r = await deliver(ev('evt_01JZ0000000000000000000E01', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX1', subject: alice.subject }));
++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'exported'], JSON.stringify(r.json));
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX1/parts');
++ assert.strictEqual(part.auth, 'Bearer tok_wiki');
++ const names = part.body.files.map((f) => f.name).sort();
++ assert.deepStrictEqual(names, ['pages.json', 'revisions.json', 'roles.json', 'spaces.json', 'watching.json']);
++ assert.ok(!JSON.stringify(part.body).includes(bob.subject));
++ });
++
++ await check('the deletion deletes her own space, removes her role and watch, leaves her edit authorless, and confirms once', async () => {
++ const event = ev('evt_01JZ0000000000000000000D01', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE1', subject: alice.subject });
++ const r = await deliver(event);
++ assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'erased'], JSON.stringify(r.json));
++ const space = await db.maybe('SELECT owner, created_by, deleted_at FROM wiki_spaces WHERE id = $1', [mine.id]);
++ assert.ok(space.deleted_at, 'her space is deleted');
++ assert.deepStrictEqual([space.owner, space.created_by], ['deleted', 'deleted']);
++ assert.strictEqual((await H.req(h, 'GET', '/s/alice-notes')).status, 410, 'and answers Gone');
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_permissions WHERE subject = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_watchers WHERE subject = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_page_revisions WHERE author = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_citations WHERE attached_by = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_module_dirty WHERE subject = $1', [alice.subject]), 0);
++ const edit = await db.maybe('SELECT author, content FROM wiki_page_revisions WHERE entity_id = $1 AND number = 2', [bobsPage.id]);
++ assert.strictEqual(edit.author, null);
++ assert.ok(edit.content.includes('Alice improved it'), 'the text never changes');
++ assert.strictEqual(await count('SELECT count(*) FROM wiki_spaces WHERE id = $1 AND deleted_at IS NULL AND owner = $2', [theirs.id, bob.subject]), 1, 'bob\'s space stays');
++ const conf = stub.calls.filter((c) => c.url === '/internal/account-deletions/del_01JZ0000000000000000000DE1/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.strictEqual(conf[0].body.erased.wiki_spaces, 1);
++ assert.strictEqual(conf[0].body.erased.wiki_permissions, 2, 'her owner row in her own space and her editor role in bob\'s');
++ assert.ok(conf[0].body.retained.tombstones >= 4);
++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
++ });
++
++ await check('revisions and citations stay append-only: outside the erasure nothing changes, inside it only the id', async () => {
++ await assert.rejects(db.exec('UPDATE wiki_page_revisions SET author = NULL WHERE entity_id = $1 AND number = 1', [bobsPage.id]), /immutable/);
++ await assert.rejects(db.tx(async (tx) => {
++ await tx.value("SELECT set_config('wiki.account_erasure', 'on', true)");
++ await tx.exec("UPDATE wiki_page_revisions SET content = 'rewritten', author = NULL WHERE entity_id = $1 AND number = 1", [bobsPage.id]);
++ }), /immutable/);
++ await assert.rejects(db.tx(async (tx) => {
++ await tx.value("SELECT set_config('wiki.account_erasure', 'on', true)");
++ await tx.exec("UPDATE wiki_citations SET url = 'https://evil.example/', attached_by = NULL WHERE entity_id = $1", [bobsPage.id]);
++ }), /immutable/);
++ });
++
++ await check('the route refuses a bad signature and a request that came through a proxy', async () => {
++ const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
++ assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
++ assert.strictEqual((await deliver(event, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);
++ });
++ } finally {
++ await h.stop();
++ await stub.close();
++ }
++ console.log(failures ? `\n${failures} failed` : '\nall passed');
++ process.exit(failures ? 1 : 0);
++})();
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 3d3b6cb..fbda586 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -40,7 +40,7 @@ const quiet = process.env.WIKI_TEST_LOG ? console : { log() {}, warn() {}, error
* A running Wiki on a database of its own (PGlite by default; WIKI_TEST_STORE=pg: the PostgreSQL + PgBouncer
* containers). opts.db: boot on an existing handle (a restart keeps the data); h.stop() closes what boot opened.
*/
--async function boot({ env = {}, fetch: fetchImpl, db: givenDb = null, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet, indexnow = undefined } = {}) {
++async function boot({ env = {}, fetch: fetchImpl, db: givenDb = null, now, workers = false, tokens, rateLimits = false, limitsNow = null, log = quiet, indexnow = undefined, accountSend = null } = {}) {
const owned = givenDb ? null : await testDb();
const db = givenDb || owned.db;
const config = load({
@@@ -52,7 +52,7 @@ async function boot({ env = {}, fetch: fetchImpl, db: givenDb = null, now, worke
config, db, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
-- indexnow,
++ indexnow, accountSend,
});
const base = `http://127.0.0.1:${h.server.address().port}`;
const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };