openvibers/openvibe.blog Commits

Blog: account export and deletion (ADR-033) — a person's own blog closed with its posts removed as its owner would, memberships and drafts deleted, posts elsewhere made authorless (append-only rows only inside the erasure transaction), reviews kept; loopback /internal/events; subscriptions at boot (SDK v0.37.0) (#15)

OP OpenVibers committed 9 Oct 2026 · c97f7df866 · 14 files, +332 −11

Changes

ddiff --git a/.env.example b/.env.example
iindex c9ffc71..7c132d9 100644
---- a/.env.example
++++ b/.env.example
@@@ -59,6 +59,9 @@ OV_COMMUNITY_INTERNAL_URL=http://127.0.0.1:4200
# OpenVibe.Events — the outbox relay runs only when EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set
#EVENTS_URL=http://127.0.0.1:4300
++# OpenVibe.Events → Blog (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.
++#BLOG_EVENTS_SECRET=
#EVENTS_RELAY_INTERVAL_MS=2000
# Scheduled publication worker (in-process); off: BLOG_WORKER=off
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 75ceed8..9d7ed37 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,5 +2,11 @@
## Unreleased
++- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `BLOG_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).
++ - **Their own blog:** its posts are removed and the blog closed.
++ - **Elsewhere:** their posts stay without their id.
++ - **Deleted:** memberships, drafts and the name cache.
++ - **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`.
- openvibe-contracts moves from v0.79.0 to v0.97.0 (pin, lockfile and `node_modules`); nothing in the range breaks Blog, and the contracts' own service check is green. All twelve `blog.*` ids are now defined by the release, so `server/auth/capabilities.js` drops its local fallback for proposed ids and sends every check through the library's grant rule; `test/capabilities.test.js` pins that every guarded id is defined and that exact, prefix, denied and unknown ids answer as `capabilities.check()` does. README and STATUS.json name v0.97.0.
- Every page is rendered through `openvibe-publishing/layout` (v1.2.0, on `openvibe-shared/shell` v2.6.0): the head, the Frame, the noscript navigation, the footer and its init come from the shared document; robots and the canonical still come from the indexability gate's decision, and a blog's theme preset stays on its content surface. The shell adds `web-runtime.js`, so the home page's JS budget is raised to 5 files, 245 KB, 59 KB brotli (measured 239.1 KB, 56.3 KB brotli; 212.2 KB, 49.9 KB before).
ddiff --git a/README.md b/README.md
iindex a7d657b..1568d6c 100644
---- a/README.md
++++ b/README.md
@@@ -224,6 +224,24 @@ OpenVibe.Network proxies this feed at `openvibe.network/api/v1/changelog`. Every
| `blog.moderation.action` | internal. Staff unpublished (`post.unpublished`) or deleted (`post.deleted`) a post that only their staff powers let them touch, for the network's moderation audit log (ADR-022, `common.moderation-action@1`). Never the content. |
| `blog.index_document.upserted` / `.deleted` | Documents and tombstones in `search.index-document@1` form, with a monotonic index revision (`createIndexSequencer`). Only published, public, listable posts are upserted; every other state is a tombstone. A post that was never indexed gets no tombstone. |
++### 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
++`BLOG_EVENTS_SECRET`. They are answered by `server/domain/account-data.js` over `openvibe-sdk/account-data`, with one
++receipt per export and deletion in `account_data_events`.
++
++- **Export:** the person's blog, memberships, posts, revisions, drafts and reviews.
++- **Their own member blog:** every post is removed the way its owner would remove it (`posts.remove`: unpublished,
++ out of Search and the feeds). The blog is then suspended and loses its title, description and owner; the handle
++ stays reserved.
++- **Deleted:** their memberships, drafts and display-name cache.
++- **Authorless:** posts they wrote on other blogs stay published, with `author_subject` set to `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` (`blog.account_erasure`, migration
++ `0002_account_erasure.sql`); the text never changes.
++- **Kept:** post reviews, since a person's approval is what lets reviewed text stay published.
++
## Capabilities (released in openvibe-contracts v0.97.0)
Service tokens use audience `openvibe.blog`, with one capability per route. The person the service
@@@ -275,6 +293,9 @@ Each grant is `[client, capability, audience]`:
- `[blog, identity.subject.resolve, openvibe.network]`
- `[blog, events.event.publish, openvibe.events]`
++- `[blog, events.subscription.manage, openvibe.events]` (the two account subscriptions, created at boot), then, last and
++ once the release is live, `[blog, network.account.export.contribute, openvibe.network]` and
++ `[blog, network.account.deletion.confirm, openvibe.network]` (ADR-033)
- `[blog, community.comment.write, openvibe.community]`
- `[blog, community.comment.moderate, openvibe.community]` (optional: hides the threads of deleted
or no-longer-public posts)
ddiff --git a/STATUS.json b/STATUS.json
iindex 36dc1ce..8c9dde7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,13 +8,13 @@
"port": 4810,
"domain": "openvibe.blog",
"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-contracts v0.97.0",
"openvibe-shared v2.13.0",
-- "openvibe-sdk v0.35.0"
++ "openvibe-sdk v0.37.0"
],
"capabilities": [
"blog.blog.create",
ddiff --git a/deploy/nginx/openvibe.blog.conf b/deploy/nginx/openvibe.blog.conf
iindex 65e4276..b80bed1 100644
---- a/deploy/nginx/openvibe.blog.conf
++++ b/deploy/nginx/openvibe.blog.conf
@@@ -102,6 +102,10 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4810/metrics) ──
location = /metrics { return 404; }
++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
++ # Events posts to the service's loopback port directly; never proxied from outside (ADR-033).
++ location /internal/ { return 404; }
++
# ── Pages, feeds, sitemaps, robots.txt, llms.txt, static assets ──
location / {
proxy_pass http://127.0.0.1:4810;
ddiff --git a/migrations/0002_account_erasure.sql b/migrations/0002_account_erasure.sql
nnew file mode 100644
iindex 0000000..0f12b8d
---- /dev/null
++++ b/migrations/0002_account_erasure.sql
@@@ -0,0 +1,65 @@
++-- phase: expand
++-- ADR-033 account deletion (server/domain/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.
++--
++-- Post revisions and citations stay append-only, with one exception. Inside the account-erasure transaction, and
++-- only there (it sets blog.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 blog_post_revisions_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ IF current_setting('blog.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 'blog_post_revisions rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_revision_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_revisions rows are never deleted outside a recorded purge' USING ERRCODE = 'restrict_violation';
++ END IF;
++ RETURN OLD;
++END
++$$;
++
++CREATE OR REPLACE FUNCTION blog_post_citations_guard() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN
++ IF TG_OP = 'UPDATE' THEN
++ IF current_setting('blog.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 'blog_post_citations rows are immutable' USING ERRCODE = 'restrict_violation';
++ END IF;
++ IF NOT EXISTS (SELECT 1 FROM blog_post_citation_purges WHERE entity_id = OLD.entity_id) THEN
++ RAISE EXCEPTION 'blog_post_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 e07e5c6..52346e5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,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"
},
@@@ -907,9 +907,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 a52bca6..7985f1f 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,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 f9c835b..cd16085 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -24,6 +24,8 @@ const access = require('./domain/access');
const { createBlogs } = require('./domain/blogs');
const { createPublication } = require('./domain/publication');
const { createPosts } = require('./domain/posts');
++const accountDataLib = require('./domain/account-data');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createReading } = require('./domain/reading');
const { createEffects } = require('./domain/effects');
const { createPeople } = require('./clients/network');
@@@ -113,6 +115,17 @@ async function createApp(opts = {}) {
ctx.limits = createActorLimits({ config, now: opts.limitsNow || (() => Date.now()), registry: metrics.registry, log, valkey });
app.use(contracts.http.middleware());
++
++ // ── OpenVibe.Events → Blog (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 domain/account-data.js. It reads the raw body itself (the v2 signature covers it) and refuses a request
++ // that came through a proxy. The sender posts to Network's internal routes with Blog's own token.
++ ctx.accountData = accountDataLib.create({ db: store.db, posts, log });
++ ctx.accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Blog cannot answer account events'); });
++ app.post('/internal/events', ctx.accountData.consumer({ secrets: config.events.secrets, send: ctx.accountSend, log }));
++
app.use(helmet({
contentSecurityPolicy: {
directives: {
ddiff --git a/server/config.js b/server/config.js
iindex c8711e7..33aafe0 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -107,6 +107,10 @@ function load(env = process.env) {
events: {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // OpenVibe.Events → Blog (ADR-033 account export and deletion, server/domain/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.
++ secrets: String(env.BLOG_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
// Scheduled publication worker.
ddiff --git a/server/domain/account-data.js b/server/domain/account-data.js
nnew file mode 100644
iindex 0000000..31eac5a
---- /dev/null
++++ b/server/domain/account-data.js
@@@ -0,0 +1,80 @@
++'use strict';
++
++/**
++ * Account export and deletion → Blog (ADR-033; openvibe-sdk/account-data). What Blog holds about a person:
++ *
++ * their own, deleted drafts, the display-name cache (subject_projections) and their blog memberships
++ * their member blog every post in it is removed the way its owner would remove it (posts.remove, acting as
++ * them: unpublished, out of Search and the feeds), then the blog is suspended, loses its
++ * title, description and owner (the handle stays reserved)
++ * posts elsewhere stay, made authorless: author_subject becomes 'deleted' (an official post a staff member
++ * wrote stays published); added_by and purged_by become NULL; revisions and citations are
++ * append-only, so only inside the erasure transaction (blog.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 post reviews: a person's approval is what lets reviewed text stay published
++ * (openvibe-publishing/authorship), counted as retained
++ *
++ * The changelog tables hold commit subjects and git authors, never an account; they are not touched.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const anonymize = { anonymize: {} };
++
++const TABLES = [
++ { table: 'blog_post_drafts', subject: 'owner', file: 'drafts.json', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },
++ { table: 'subject_projections', subject: 'subject', file: 'profile.json', order: 'subject' },
++ { table: 'blog_memberships', subject: 'added_by', file: null, erase: anonymize },
++ { table: 'blog_post_revision_purges', subject: 'purged_by', file: null, erase: anonymize },
++ { table: 'blog_post_citation_purges', subject: 'purged_by', file: null, erase: anonymize },
++ { table: 'blog_post_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 blogs = await db.many('SELECT id, kind, handle, title, description, theme, language, status, created_at FROM blogs WHERE owner_subject = $1', [subject]);
++ if (blogs.length) files.push({ name: 'blogs.json', content: blogs });
++ const memberships = await db.many('SELECT blog_id, role, created_at, updated_at FROM blog_memberships WHERE subject = $1 ORDER BY created_at DESC', [subject]);
++ if (memberships.length) files.push({ name: 'memberships.json', content: memberships });
++ const posts = await db.many(`SELECT id, blog_id, slug, state, visibility, series_id, published_revision, first_published_at, published_at, created_at, updated_at, deleted_at
++ FROM blog_posts WHERE author_subject = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (posts.length) files.push({ name: 'posts.json', content: posts });
++ const revisions = await db.many(`SELECT entity_id, number, kind, content, fields, message, created_at FROM blog_post_revisions
++ WHERE author = $1 ORDER BY created_at DESC LIMIT 5000`, [subject]);
++ if (revisions.length) files.push({ name: 'revisions.json', content: revisions });
++ return files;
++}
++
++/** The account-data handle over Blog's database and posts (posts.remove for the person's own blog). */
++function create({ db, posts, log = console } = {}) {
++ async function extraErase(t, subjects, counts) {
++ // Their member blog: each post removed as its owner would remove it (a savepoint in this transaction), while
++ // their membership still says they own it; then the blog is suspended and loses what names them.
++ const own = await t.many("SELECT id, owner_subject FROM blogs WHERE kind = 'member' AND owner_subject = ANY($1::text[])", [subjects]);
++ for (const blog of own) {
++ const viewer = { kind: 'user', subject: blog.owner_subject, staff: false };
++ const live = await t.many("SELECT id FROM blog_posts WHERE blog_id = $1 AND state <> 'deleted' ORDER BY created_at", [blog.id]);
++ for (const p of live) await posts.remove(viewer, await posts.get(p.id));
++ counts.add(counts.erased, 'blog_posts', live.length);
++ }
++ counts.add(counts.retained, 'blogs', await t.exec(`UPDATE blogs SET status = 'suspended', owner_subject = NULL, title = 'Deleted blog', description = NULL, updated_at = $2
++ WHERE kind = 'member' AND owner_subject = ANY($1::text[])`, [subjects, Date.now()]));
++ counts.add(counts.erased, 'blog_memberships', await t.exec('DELETE FROM blog_memberships WHERE subject = ANY($1::text[])', [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE blog_posts SET author_subject = 'deleted' WHERE author_subject = 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('blog.account_erasure', 'on', true)");
++ counts.add(counts.retained, 'tombstones', await t.exec('UPDATE blog_post_revisions SET author = NULL WHERE author = ANY($1::text[])', [subjects]));
++ counts.add(counts.retained, 'tombstones', await t.exec(`UPDATE blog_post_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 blog_post_citations SET attached_by = NULL WHERE attached_by = ANY($1::text[])', [subjects]));
++ await t.value("SELECT set_config('blog.account_erasure', 'off', true)");
++ }
++ return createAccountData({
++ db, service: 'blog', tables: TABLES, extraExport, extraErase, log,
++ note: 'Your own blog\'s posts are deleted and the blog closed. Posts you wrote on other blogs stay without your name.',
++ });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/index.js b/server/index.js
iindex fecc76f..1c7b63f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -7,16 +7,17 @@
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service, docs/service.md's Blog family): the worker and the
* changelog stop taking new work, the HTTP drain runs (defaults 4000/5000), then the outbox settles
* and the store closes. Exported so a test can inject `exit` and `signals: false`.
*/
--function createLifecycle({ server, ctx, exit, signals }) {
++function createLifecycle({ server, ctx, exit, signals, extra = [] }) {
return gracefulStop({
name: 'Blog', server, deadlineExitCode: 0, exit, signals,
-- stop: [() => ctx.worker.stop(), () => ctx.changelog.stop()],
++ stop: [() => ctx.worker.stop(), () => ctx.changelog.stop(), ...extra],
close: [() => ctx.outbox.stop(), () => ctx.store.close()],
});
}
@@@ -35,7 +36,13 @@ async function start() {
// The network changelog and patch notes run where the worker runs (one process).
if (config.worker.enabled) ctx.changelog.start();
-- createLifecycle({ server, ctx });
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // BLOG_EVENTS_SECRET or the client secret.
++ const subscriptions = startSubscriptions({
++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
++ createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
return { server, ctx };
}
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..38a1ac8
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,118 @@
++'use strict';
++/**
++ * ADR-033: Blog'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 has her own blog with a published post, and is an author on
++ * Bob's blog with a published post there. Her export carries her blog, memberships, posts and revisions. Her deletion
++ * removes her own blog's posts (as its owner would) and closes it, removes her memberships, leaves her post on Bob's
++ * blog published without her id (the text unchanged), and confirms once.
++ */
++const assert = require('assert');
++const http = require('http');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++const SECRET = `whsec_${'fixture'.repeat(6)}`;
++const LONG = Array.from({ length: 120 }, (_, i) => `word${i}`).join(' ');
++
++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_blog', 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)) };
++}
++
++(async () => {
++ const stub = await startNetworkStub();
++ const t = await boot({ env: { BLOG_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'blog', clientSecret: 'shh' }) });
++ const alice = t.network.addUser('alice', { display_name: 'Alice Writer' });
++ const bob = t.network.addUser('bob', { display_name: 'Bob Editor' });
++ const db = t.ctx.store.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(`${t.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 });
++ const post = async (user, handle, title) => {
++ const r = await t.get(`/api/v1/blogs/${handle}/posts`, { as: user, json: { title, body: `${title}. ${LONG}` } });
++ assert.strictEqual(r.status, 201, r.text);
++ const p = r.json().post;
++ assert.strictEqual((await t.get(`/api/v1/posts/${p.id}/publish`, { as: user, json: {} })).status, 200);
++ return p;
++ };
++ let mine;
++ let onBobs;
++
++ try {
++ await check('alice\'s own blog and post, and her post as an author on bob\'s blog', async () => {
++ assert.strictEqual((await t.get('/api/v1/blogs', { as: alice, json: {} })).status, 201);
++ assert.strictEqual((await t.get('/api/v1/blogs', { as: bob, json: {} })).status, 201);
++ assert.strictEqual((await t.get(`/api/v1/blogs/bob/members/${alice.subject}`, { method: 'PUT', as: bob, json: { role: 'author' } })).status, 200);
++ mine = await post(alice, 'alice', 'Alice at home');
++ onBobs = await post(alice, 'bob', 'Alice visiting');
++ });
++
++ await check('the export carries her blog, memberships, posts and revisions, 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_blog');
++ const names = part.body.files.map((f) => f.name);
++ for (const f of ['blogs.json', 'memberships.json', 'posts.json', 'revisions.json']) assert.ok(names.includes(f), `${f} in ${names}`);
++ assert.strictEqual(part.body.files.find((f) => f.name === 'posts.json').content.length, 2);
++ assert.ok(!JSON.stringify(part.body).includes(bob.subject));
++ });
++
++ await check('the deletion closes her blog, removes its posts and her memberships, leaves her post on bob\'s blog authorless; 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 blog = await db.maybe("SELECT status, owner_subject, title FROM blogs WHERE handle = 'alice'");
++ assert.deepStrictEqual([blog.status, blog.owner_subject, blog.title], ['suspended', null, 'Deleted blog']);
++ assert.strictEqual(await db.value('SELECT state FROM blog_posts WHERE id = $1', [mine.id]), 'deleted');
++ assert.strictEqual((await t.get('/alice')).status, 404, 'her blog is no longer served');
++ const visiting = await db.maybe('SELECT state, author_subject FROM blog_posts WHERE id = $1', [onBobs.id]);
++ assert.deepStrictEqual([visiting.state, visiting.author_subject], ['published', 'deleted'], 'her post on bob\'s blog stays, authorless');
++ assert.strictEqual(await count('SELECT count(*) FROM blog_memberships WHERE subject = $1', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) FROM blog_post_revisions WHERE author = $1', [alice.subject]), 0);
++ assert.ok((await db.value('SELECT content FROM blog_post_revisions WHERE entity_id = $1 ORDER BY number DESC LIMIT 1', [onBobs.id])).includes('Alice visiting'), 'the text never changes');
++ assert.strictEqual(await count("SELECT count(*) FROM blogs WHERE handle = 'bob' AND status = 'active' AND owner_subject = $1", [bob.subject]), 1);
++ 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.blog_posts, 1);
++ assert.strictEqual(conf[0].body.erased.blog_memberships, 2);
++ assert.strictEqual(conf[0].body.retained.blogs, 1);
++ assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
++ assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
++ });
++
++ await check('revisions stay append-only outside the erasure, and inside it only the id may change', async () => {
++ await assert.rejects(db.exec('UPDATE blog_post_revisions SET author = NULL WHERE entity_id = $1', [onBobs.id]), /immutable/);
++ await assert.rejects(db.tx(async (tx) => {
++ await tx.value("SELECT set_config('blog.account_erasure', 'on', true)");
++ await tx.exec("UPDATE blog_post_revisions SET content = 'rewritten' WHERE entity_id = $1", [onBobs.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 t.close();
++ await stub.close();
++ }
++ done();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex bb08db4..6283224 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,7 @@ async function boot(opts = {}) {
let built = null;
async function start() {
const config = configLib.load(env);
-- built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow });
++ built = await createApp({ config, store: createStore(testdb.db, { now: clock.now }), now: clock.now, log: opts.log || quiet, entitlementCheck: opts.entitlementCheck, limitsNow: opts.limitsNow, indexnow: opts.indexnow, accountSend: opts.accountSend });
// Warm the shared JWKS cache so an immediately-verified token does not race the first fetch.
await require('openvibe-sdk/auth').jwksClient(`${network.url}/api/.well-known/jwks`).keysForKid(null);
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });