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)
Changes
@@@ -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@@@ -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.@@@ -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 ofthe index events). All but the Network key are optional at runtime and degrade to explicit failurestates.- 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@@@ -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",@@@ -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;nnew file mode 100644@@@ -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++$$;@@@ -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"@@@ -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"},@@@ -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 }));@@@ -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 || ''),@@@ -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(), fetchconst 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');nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++})();@@@ -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, workeconfig, 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(); };
@@ -44,6 +44,9 @@ OV_OAUTH_CLIENT_SECRET=
@@ -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).
# 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
#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
#EVENTS_RELAY_INTERVAL_MS=2000
# OpenVibe.Community — discussion threads (community.comment.write; community.comment.moderate
# OpenVibe.Community — discussion threads (community.comment.write; community.comment.moderate
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## 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.
- 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-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.
- `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.
@@ -282,13 +282,33 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -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-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`
- `openvibe-publishing` v1.3.0 (async PostgreSQL stores, ingest and publication chassis), `openvibe-contracts`
v0.107.0, `openvibe-shared` v2.15.0
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.Network (SSO, JWKS, service principal `wiki`), OpenVibe.Events, OpenVibe.Community,
OpenVibe.Sources, OpenVibe.Media, OpenVibe.VIP (VIP spaces and pages), OpenVibe.Search (consumer of
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
the index events). All but the Network key are optional at runtime and degrade to explicit failure
states.
states.
- OpenVibe.AI for proposals (not wired to Wiki yet: the proposal API is the seam).
- 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
## Capabilities
@@ -8,12 +8,12 @@
@@ -8,12 +8,12 @@
"port": 4800,
"port": 4800,
"domain": "openvibe.wiki",
"domain": "openvibe.wiki",
"contracts": "openvibe-contracts v0.112.0",
"contracts": "openvibe-contracts v0.112.0",
- "sdk": "openvibe-sdk v0.35.0",
+ "sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.15.0",
"shared": "openvibe-shared v2.15.0",
"packages": [
"packages": [
"openvibe-publishing v1.3.0",
"openvibe-publishing v1.3.0",
"openvibe-shared v2.15.0",
"openvibe-shared v2.15.0",
- "openvibe-sdk v0.35.0"
+ "openvibe-sdk v0.37.0"
],
],
"capabilities": [
"capabilities": [
"wiki.space.create",
"wiki.space.create",
@@ -118,6 +118,10 @@ server {
@@ -118,6 +118,10 @@ server {
proxy_pass http://127.0.0.1:4800;
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 / {
location / {
limit_req zone=ovwiki_write burst=20 nodelay;
limit_req zone=ovwiki_write burst=20 nodelay;
proxy_pass http://127.0.0.1:4800;
proxy_pass http://127.0.0.1:4800;
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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
+$$;
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -893,9 +893,9 @@
@@ -893,9 +893,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.112.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.15.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -23,7 +23,7 @@ const { createActorLimits } = require('./http/actor-limits');
@@ -23,7 +23,7 @@ const { createActorLimits } = require('./http/actor-limits');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const VERSION = require('../package.json').version;
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();
const app = express();
// Every resolved actor carries its wiki roles (read once per request), so the synchronous access checks work.
// 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)) };
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
@@ -63,6 +63,15 @@ function createApp({ config, svc, viewers: baseViewers, platform, keys, db, valk
});
});
app.use(cookieParser());
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());
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/', limiter(15 * 60000, 60));
app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
app.use('/auth', createSessionRoutes({ config, viewers, log, fetchImpl }));
@@ -56,6 +56,10 @@ function load(env = process.env) {
@@ -56,6 +56,10 @@ function load(env = process.env) {
// Other services (all optional: each integration degrades to an explicit failure state).
// Other services (all optional: each integration degrades to an explicit failure state).
eventsUrl: trim(env.EVENTS_URL || ''),
eventsUrl: trim(env.EVENTS_URL || ''),
eventsRelayIntervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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'),
communityUrl: trim(env.OV_COMMUNITY_URL || 'https://openvibe.community'),
communityInternalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || ''),
communityInternalUrl: trim(env.OV_COMMUNITY_INTERNAL_URL || ''),
sourcesInternalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
sourcesInternalUrl: trim(env.OV_SOURCES_INTERNAL_URL || ''),
@@ -17,6 +17,8 @@ const { createWikiService } = require('./wiki/service');
@@ -17,6 +17,8 @@ const { createWikiService } = require('./wiki/service');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
const { createViewerResolver } = require('./auth/viewer');
const { createViewerResolver } = require('./auth/viewer');
const { createApp } = require('./app');
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
* 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');
@@ -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
* 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.
* 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({
return gracefulStop({
name: 'Wiki', server, log, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 0, exit, signals,
name: 'Wiki', server, log, drainMs: 8000, deadlineMs: 10000, deadlineExitCode: 0, exit, signals,
stop: [
stop: [
() => timers.forEach(clearInterval),
() => timers.forEach(clearInterval),
() => platform.outbox.stop(),
() => platform.outbox.stop(),
+ ...extra,
],
],
close: [
close: [
() => { if (closeDb) return db.close(); },
() => { if (closeDb) return db.close(); },
@@ -39,7 +42,7 @@ function createLifecycle({ server, db, valkey = null, platform, timers = [], clo
@@ -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();
config = config || load();
// PostgreSQL (ADR-035): migrate on the owner's direct connection, then serve on the pooled runtime role.
// 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).
// 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
@@ -70,7 +73,13 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
const keys = createKeyStore({ config, fetchImpl, log, publicKey });
keys.ensure().catch(() => {});
keys.ensure().catch(() => {});
const viewers = createViewerResolver({ keys, config });
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 = [];
const timers = [];
if (workers) {
if (workers) {
@@ -95,13 +104,20 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -95,13 +104,20 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
}
}
let server = null;
let server = null;
+ let subscriptions = null;
if (listen) {
if (listen) {
await new Promise((resolve) => { server = app.listen(config.port, config.host, resolve); });
await new Promise((resolve) => { server = app.listen(config.port, config.host, resolve); });
server.keepAliveTimeout = 65000;
server.keepAliveTimeout = 65000;
log.log(`[Wiki] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
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() {
async function stop() {
return lifecycle.stop('stop');
return lifecycle.stop('stop');
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -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);
+})();
@@ -40,7 +40,7 @@ const quiet = process.env.WIKI_TEST_LOG ? console : { log() {}, warn() {}, error
@@ -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
* 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.
* 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 owned = givenDb ? null : await testDb();
const db = givenDb || owned.db;
const db = givenDb || owned.db;
const config = load({
const config = load({
@@ -52,7 +52,7 @@ async function boot({ env = {}, fetch: fetchImpl, db: givenDb = null, now, worke
@@ -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,
config, db, publicKey, log, listen: true, workers, rateLimits, now, limitsNow,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
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 base = `http://127.0.0.1:${h.server.address().port}`;
const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };
const stop = async ({ keepDb = false } = {}) => { await h.stop(); if (owned && !keepDb) await owned.close(); };