openvibers/openvibe.space Commits

Space: account export and deletion again (ADR-033, openvibe-sdk/account-data): a person's spaces with their files, their list items, private storage and sign-ins; deletion closes their published spaces and removes their data from everyone's (#59)

OP OpenVibers committed 28 days ago · 06c52329d6 · 10 files, +238 −4

Changes

ddiff --git a/.env.example b/.env.example
iindex 9d39c68..160df57 100644
---- a/.env.example
++++ b/.env.example
@@@ -43,6 +43,8 @@ DATABASE_DIRECT_URL=
SPACE_PUBLIC_HOSTING=off
# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
#SPACE_AI=on
++# OpenVibe.Events → Space (account export and deletion, server/account-data.js): signs deliveries to /internal/events (32+ characters).
++#SPACE_EVENTS_SECRET=
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#SPACE_SPACES_PER_OWNER=25
#SPACE_AI_BUILDS_PER_DAY=20
ddiff --git a/README.md b/README.md
iindex 7d37f00..1e970b9 100644
---- a/README.md
++++ b/README.md
@@@ -37,7 +37,11 @@ OpenVibe.Host is the hosting price comparison; its copy of the engine is being r
## Depends on
- **OpenVibe.Network:** sign-in (OAuth with PKCE), tokens and their keys, project membership.
--- **OpenVibe.Events:** the outbox relay, when `EVENTS_URL` is set.
++- **OpenVibe.Events:** the outbox relay, when `EVENTS_URL` is set, and account export and deletion (ADR-033):
++ `network.account.export_requested` and `network.account.deleted` reach the loopback `/internal/events` consumer
++ (`server/account-data.js`, openvibe-sdk/account-data), signed with `SPACE_EVENTS_SECRET`. A person's spaces are
++ exported with their files; on deletion their published spaces stop serving and the spaces go with what others kept
++ in them, and their list items, private storage and sign-ins leave other people's spaces.
- **OpenVibe.Media:** tenant objects, when `SPACE_OBJECT_STORE=media`.
- **OpenVibe.Billing:** storage readings, when `SPACE_BILLING_INTERVAL_MS` is on.
- **OpenVibe.Services:** reads the resource index and sends control requests.
@@@ -97,6 +101,7 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
`migrations/0002_initial.sql` through `0005_git_sources.sql` create the hosting tables, `0006_billing_readings.sql` the usage-reading outbox,, indexes and immutable-artifact triggers from Host Stage B with `space_` names. They run through `openvibe-sdk/db` at boot; tables are never created by a request. `0001_initial.sql` remains the historical forum schema, which this release does not drop. This is a code move, with no data copy.
`migrations/0009_spaces.sql` creates the spaces tables (`space_apps`, `space_app_files`, `space_app_projects`, `space_app_items`, `space_app_kv`, `space_app_grants`, `space_app_codes`, `space_app_tokens`, `space_app_counters`). Only hashes of sign-in codes and tokens are kept.
++`migrations/0010_account_data.sql` creates openvibe-sdk/account-data's `account_data_events` (the forum-era table of that name is kept as `account_data_events_forum`).
Request telemetry is aggregated by route template, method and status class, never by raw URL. Once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges, is written to this service's own `telemetry_samples` table (openvibe-sdk 0.46 telemetry, the `platform.telemetry-sample@1` shape), which `migrations/0008_telemetry.sql` creates; rows older than 14 days are deleted daily. `/shared` files, static assets, health, readiness and `/metrics` are not counted. `server/app.js` mounts openvibe-sdk's `telemetryMiddleware` (a no-op until init); `server/index.js` calls `telemetry.init()` at production start, runs the 14-day retention and flushes on stop.
@@@ -105,6 +110,7 @@ Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600).
| Variable | Default | Purpose |
| --- | --- | --- |
| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation (and publishing spaces) until launch |
++| `SPACE_EVENTS_SECRET` | unset | Signs OpenVibe.Events deliveries to `/internal/events` (account export and deletion); unset answers 503 |
| `SPACE_AI`, `OV_AI_INTERNAL_URL` | `on`, `http://127.0.0.1:4700` | The space builder and spaces' AI through OpenVibe.AI |
| `SPACE_SPACES_PER_OWNER`, `SPACE_AI_BUILDS_PER_DAY`, `SPACE_AI_ASKS_PER_SPACE_PER_DAY` | 25, 20, 500 | Spaces per person, AI builds per person a day, AI answers per space a day (more `SPACE_SPACE_*` limits in `server/hosting-config.js`) |
| `SPACE_SITES_DOMAIN` | `openvibe.website` | Tenant names |
ddiff --git a/deploy/nginx/openvibe.space.conf b/deploy/nginx/openvibe.space.conf
iindex a415194..d26b280 100644
---- a/deploy/nginx/openvibe.space.conf
++++ b/deploy/nginx/openvibe.space.conf
@@@ -144,6 +144,21 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
++ # ── Spaces: the editor saves a space's files (SPACE_SPACE_MAX_BYTES, 1 MB of text) as JSON. ──
++ location /spaces/ {
++ client_max_body_size 3m;
++ limit_req zone=ovspace_api burst=30 nodelay;
++ proxy_pass http://127.0.0.1:4940;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ }
++
++ # ── OpenVibe.Events → Space (account export and deletion) is loopback only; Node also refuses a proxied request. ──
++ location /internal/ { return 404; }
++
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4940/metrics). Node also
# refuses any request carrying X-Forwarded-For, which every location above sets. ──
location ~* ^/metrics(/|$) { return 404; }
ddiff --git a/migrations/0010_account_data.sql b/migrations/0010_account_data.sql
nnew file mode 100644
iindex 0000000..23950da
---- /dev/null
++++ b/migrations/0010_account_data.sql
@@@ -0,0 +1,14 @@
++-- phase: expand
++-- Account export and deletion (ADR-033) through openvibe-sdk/account-data (server/account-data.js): its idempotency
++-- table. The forum-era table of the same name (0001: text columns, written by the forum's own consumer) is kept under
++-- another name; nothing reads it since the forum moved to OpenVibe.Community.
++ALTER TABLE IF EXISTS account_data_events RENAME TO account_data_events_forum;
++ALTER INDEX IF EXISTS account_data_events_pkey RENAME TO account_data_events_forum_pkey;
++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
++);
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..11dc2e4
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,72 @@
++'use strict';
++/**
++ * Account export and deletion → Space (ADR-033; openvibe-sdk/account-data). What Space holds about a person:
++ *
++ * their spaces exported with their files and settings; on deletion each published one stops serving (its site
++ * and deploys are marked deleted, as removing it would; the worker sweeps the objects), its
++ * Spaces project closes, and the space goes, with everything other people kept in it (by
++ * ON DELETE CASCADE: files, list items, private storage, sign-ins).
++ * in other spaces the list items they added and their private storage are exported, then deleted; the sign-ins
++ * they approved (grants, codes, tokens) go.
++ * hosting their membership of hosting projects goes (staff-only today); a project they own is closed
++ * with its sites.
++ * AI allowances today's AI build counter goes.
++ *
++ * Nothing exported is a secret: sign-in codes and tokens are stored as hashes and never exported.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ { table: 'space_app_items', subject: 'author_subject', file: 'space-list-items.json', columns: ['space_id', 'collection', 'value', 'created_at', 'updated_at'], order: 'created_at' },
++ { table: 'space_app_kv', subject: 'subject', file: 'space-private-storage.json', columns: ['space_id', 'key', 'value', 'updated_at'], order: 'updated_at' },
++ { table: 'space_app_grants', subject: 'subject', file: 'space-sign-ins.json', columns: ['space_id', 'capabilities', 'granted_at'], order: 'granted_at' },
++ { table: 'space_app_tokens', subject: 'subject', file: null },
++ { table: 'space_app_codes', subject: 'subject', file: null },
++ { table: 'space_app_counters', subject: 'scope', value: (usr) => `build:${usr}`, file: null },
++ { table: 'space_project_members', subject: 'principal', file: 'hosting-project-roles.json', columns: ['project_id', 'role', 'created_at'] },
++];
++
++async function extraExport(db, subject) {
++ const spaces = await db.many(`SELECT id, slug, name, description, instructions, capabilities, template, forked_from, visibility, site_id IS NOT NULL AS published,
++ forks, created_at, updated_at, published_at FROM space_apps WHERE owner_subject = $1 AND status = 'active' ORDER BY created_at`, [subject]);
++ if (!spaces.length) return [];
++ const files = await db.many('SELECT space_id, path, content FROM space_app_files WHERE space_id = ANY($1::text[]) ORDER BY space_id, path', [spaces.map((s) => s.id)]);
++ const byId = new Map(spaces.map((s) => [s.id, { ...s, capabilities: JSON.parse(s.capabilities || '[]'), files: [] }]));
++ for (const f of files) byId.get(f.space_id).files.push({ path: f.path, content: f.content });
++ return [{ name: 'spaces.json', content: [...byId.values()] }];
++}
++
++/** Close sites (and their deploys) the way removing them does; the worker sweeps the objects nothing references. */
++async function closeSites(t, siteIds, now) {
++ if (!siteIds.length) return 0;
++ const n = await t.exec(`UPDATE space_sites SET status = 'deleted', active_deploy_id = NULL, preview_deploy_id = NULL, preview_expires_at = NULL, deleted_at = $2, updated_at = $2
++ WHERE id = ANY($1::text[]) AND status = 'active'`, [siteIds, now]);
++ await t.exec("UPDATE space_deploys SET state = 'deleted', deleted_at = $2 WHERE site_id = ANY($1::text[]) AND state <> 'deleted'", [siteIds, now]);
++ await t.exec('DELETE FROM space_deploy_files WHERE deploy_id IN (SELECT id FROM space_deploys WHERE site_id = ANY($1::text[]))', [siteIds]);
++ return n;
++}
++
++async function extraErase(t, subjects, counts) {
++ const now = Date.now();
++ // Their spaces: published ones stop serving, then the spaces go with what others kept in them.
++ const spaces = await t.many("SELECT id, site_id FROM space_apps WHERE owner_subject = ANY($1::text[])", [subjects]);
++ await closeSites(t, spaces.map((s) => s.site_id).filter(Boolean), now);
++ counts.add(counts.erased, 'space_apps', await t.exec('DELETE FROM space_apps WHERE owner_subject = ANY($1::text[])', [subjects]));
++ // Projects they own (their Spaces project, or a staff hosting project): closed with their sites.
++ const projects = await t.many("SELECT id FROM space_projects WHERE owner_subject = ANY($1::text[]) AND status = 'active'", [subjects]);
++ if (projects.length) {
++ const ids = projects.map((p) => p.id);
++ const sites = await t.many("SELECT id FROM space_sites WHERE project_id = ANY($1::text[]) AND status = 'active'", [ids]);
++ await closeSites(t, sites.map((s) => s.id), now);
++ await t.exec("UPDATE space_projects SET status = 'deleted', deleted_at = $2, updated_at = $2 WHERE id = ANY($1::text[])", [ids, now]);
++ counts.add(counts.erased, 'space_projects', ids.length);
++ }
++ await t.exec('DELETE FROM space_app_projects WHERE owner_subject = ANY($1::text[])', [subjects]);
++}
++
++/** The account-data handle over Space's database. */
++function create({ db, log = console } = {}) {
++ return createAccountData({ db, service: 'space', tables: TABLES, extraExport, extraErase, log });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/app.js b/server/app.js
iindex b635d6f..d30e81f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -89,6 +89,21 @@ async function createApp(opts = {}) {
hosting.mountDispatch(app);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
++ // ── OpenVibe.Events → Space (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 server/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 Space's own token.
++ {
++ const hc = hosting.config;
++ const { createNetworkSender } = require('openvibe-sdk/account-data');
++ const accountData = require('./account-data').create({ db, log: opts.log || console });
++ const accountSend = opts.accountSend || (hc.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: hc.networkInternalUrl, clientId: hc.oauth.clientId, clientSecret: hc.oauth.clientSecret, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) })
++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Space cannot answer account events'); });
++ app.locals.accountData = accountData;
++ app.post('/internal/events', accountData.consumer({ secrets: hc.events.secrets, send: accountSend, log: opts.log || console }));
++ }
++
app.use(helmet({
contentSecurityPolicy: {
directives: {
ddiff --git a/server/hosting-config.js b/server/hosting-config.js
iindex 7db4858..892d503 100644
---- a/server/hosting-config.js
++++ b/server/hosting-config.js
@@@ -89,6 +89,9 @@ function load(env = process.env) {
events: {
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ // OpenVibe.Events → Space (ADR-033 account export and deletion, server/account-data.js): the secret(s) that
++ // sign its deliveries to /internal/events; the first one is what Space's subscriptions are created with.
++ secrets: list(env.SPACE_EVENTS_SECRET),
},
// OpenVibe.Billing (plan T5 step 14): daily gb-month storage readings for the bytes the local object store
// keeps (server/billing.js). SPACE_BILLING_INTERVAL_MS=0 (the default) starts nothing; Media mode emits no
ddiff --git a/server/index.js b/server/index.js
iindex 9a0ec48..ab59170 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -15,7 +15,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
const app = await createApp();
const server = app.listen(config.port, config.host, () => {
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
-- console.log(`[Space] static hosting for staff; dynamic pages and spaces planned; the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);
++ console.log(`[Space] spaces and static hosting (publishing for staff until launch); the forum is on ${config.communityUrl}; identity via ${config.networkUrl}`);
});
server.keepAliveTimeout = 65_000;
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
@@@ -25,6 +25,14 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.worker.start();
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // SPACE_EVENTS_SECRET or the client secret.
++ const hc = app.locals.hosting.config;
++ const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
++ const subscriptions = startSubscriptions({
++ topics: TOPICS, eventsUrl: hc.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: hc.events.secrets[0],
++ networkInternalUrl: hc.networkInternalUrl, clientId: hc.oauth.clientId, clientSecret: hc.oauth.clientSecret,
++ });
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
@@@ -34,7 +42,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
-- stop: [async () => { app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
++ stop: [async () => { if (subscriptions) subscriptions.stop(); app.locals.hosting.worker.stop(); await app.locals.hosting.outbox.stop(); await app.locals.hosting.billing.stop(); }],
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..81df95b
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * Account export and deletion (ADR-033) → Space (server/account-data.js). A maker's export carries their spaces with
++ * their files; a visitor's carries the list items they added, their private storage and the sign-ins they approved,
++ * never a token. Deleting the maker closes their published space's site and Spaces project and removes the space with
++ * what others kept in it; deleting the visitor removes their items, storage and sign-ins from other people's spaces.
++ * A delivery without the Events signature is refused.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { boot, check, done } = require('./stageb/boot');
++
++const ORIGIN = 'https://openvibe.space';
++const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
++
++(async () => {
++ const sent = [];
++ const accountSend = async (path, body) => { sent.push({ path, body }); return { ok: true, status: 200, json: async () => ({}) }; };
++ const t = await boot({ accountSend, env: { SPACE_EVENTS_SECRET: 'events-secret-for-tests-0123456789abcdef' } });
++ const { csrfToken } = require('../server/auth/forms');
++ const alice = t.user('alice'), bob = t.user('bob');
++ const csrf = (u) => csrfToken(t.config, { subject: u.subject });
++ const edit = (u, method, path, json) => t.api(method, path, { session: u, json, headers: { origin: ORIGIN, 'x-csrf-token': csrf(u) } });
++ const count = async (sql, args) => Number((await t.ctx.store.db.prepare(sql).get(...args)).n);
++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
++ const apply = (event) => t.app.locals.accountData.apply(event, { send: accountSend });
++ let space, slug, url;
++
++ await check('alice publishes a space; bob signs in to it, adds to its list and keeps something private', async () => {
++ const r = await t.api('POST', '/spaces/new', { session: alice, form: { csrf: csrf(alice), template: 'guestbook' }, headers: { origin: ORIGIN } });
++ slug = /^\/spaces\/([^/]+)\/edit$/.exec(r.headers.location)[1];
++ await edit(alice, 'PUT', `/spaces/${slug}/settings`, { capabilities: ['profile', 'data', 'kv'] });
++ url = (await edit(alice, 'POST', `/spaces/${slug}/publish`)).json().url;
++ space = await t.ctx.spaces.getBySlug(slug);
++ await edit(alice, 'POST', `/spaces/${slug}/preview`, { op: 'items.add', args: { collection: 'notes', value: { text: 'welcome' } } });
++ const verifier = b64url(crypto.randomBytes(32));
++ const challenge = b64url(crypto.createHash('sha256').update(verifier).digest());
++ const c = await t.api('POST', `/spaces/${slug}/connect`, { session: bob, form: { csrf: csrf(bob), challenge, state: 'state-bob', return: '/', decision: 'allow' }, headers: { origin: ORIGIN } });
++ const code = /#ov_code=([^&]+)/.exec(c.headers.location)[1];
++ const token = (await t.api('POST', `/api/v1/apps/${space.id}/token`, { json: { code, code_verifier: verifier }, headers: { origin: url } })).json().token;
++ const run = (op, args) => t.api('POST', `/api/v1/apps/${space.id}/run`, { json: { op, args }, headers: { origin: url, authorization: `Bearer ${token}` } });
++ assert.strictEqual((await run('items.add', { collection: 'notes', value: { text: 'hi from bob' } })).status, 200);
++ assert.strictEqual((await run('kv.set', { key: 'draft', value: 'secret plan' })).status, 200);
++ assert.strictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200);
++ });
++
++ await check('bob\'s export: his list item, private storage and sign-in; no token, nothing of alice\'s', async () => {
++ assert.strictEqual(await apply(ev('evt_01JZ00000000000000000SE001', 'network.account.export_requested', { export_id: 'exp_01JZ00000000000000000SEX01', subject: bob.subject })), 'exported');
++ const part = sent.find((c) => c.path === '/internal/account-exports/exp_01JZ00000000000000000SEX01/parts');
++ const files = Object.fromEntries(part.body.files.map((f) => [f.name, f.content]));
++ assert.deepStrictEqual(files['space-list-items.json'].map((i) => JSON.parse(i.value).text), ['hi from bob']);
++ assert.deepStrictEqual(files['space-private-storage.json'].map((k) => [k.key, JSON.parse(k.value)]), [['draft', 'secret plan']]);
++ assert.deepStrictEqual(files['space-sign-ins.json'].map((g) => g.space_id), [space.id]);
++ assert.ok(!files['spaces.json'], 'bob made no space');
++ assert.ok(!/token_hash|code_hash/.test(JSON.stringify(part.body)), 'no sign-in secret');
++ assert.ok(!JSON.stringify(part.body).includes('welcome'), 'nothing alice wrote');
++ });
++
++ await check('alice\'s export carries her space with its files and settings', async () => {
++ await apply(ev('evt_01JZ00000000000000000SE002', 'network.account.export_requested', { export_id: 'exp_01JZ00000000000000000SEX02', subject: alice.subject }));
++ const part = sent.find((c) => c.path === '/internal/account-exports/exp_01JZ00000000000000000SEX02/parts');
++ const spaces = part.body.files.find((f) => f.name === 'spaces.json').content;
++ assert.deepStrictEqual([spaces.length, spaces[0].slug, spaces[0].published, spaces[0].files.map((f) => f.path)], [1, slug, true, ['index.html']]);
++ assert.deepStrictEqual(spaces[0].capabilities, ['profile', 'data', 'kv']);
++ });
++
++ await check('deleting bob removes his item, storage and sign-ins from alice\'s space; hers stay; once', async () => {
++ const event = ev('evt_01JZ00000000000000000SD001', 'network.account.deleted', { deletion_id: 'del_01JZ00000000000000000SDE01', subject: bob.subject });
++ assert.strictEqual(await apply(event), 'erased');
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_items WHERE author_subject = ?', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_kv WHERE subject = ?', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_tokens WHERE subject = ?', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_grants WHERE subject = ?', [bob.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_items WHERE author_subject = ?', [alice.subject]), 1, 'alice\'s note stays');
++ const conf = sent.filter((c) => c.path === '/internal/account-deletions/del_01JZ00000000000000000SDE01/confirmations');
++ assert.strictEqual(conf.length, 1);
++ assert.deepStrictEqual([conf[0].body.erased.space_app_items, conf[0].body.erased.space_app_kv], [1, 1]);
++ assert.notStrictEqual(await apply(event), 'erased', 'a redelivery never erases twice');
++ });
++
++ await check('deleting alice closes her space\'s site and Spaces project and removes the space', async () => {
++ assert.strictEqual(await apply(ev('evt_01JZ00000000000000000SD002', 'network.account.deleted', { deletion_id: 'del_01JZ00000000000000000SDE02', subject: alice.subject })), 'erased');
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_apps WHERE owner_subject = ?', [alice.subject]), 0);
++ assert.strictEqual(await count('SELECT count(*) AS n FROM space_app_items WHERE space_id = ?', [space.id]), 0, 'what was kept in it goes with it');
++ assert.strictEqual(await count("SELECT count(*) AS n FROM space_sites WHERE id = ? AND status = 'deleted'", [space.site_id]), 1);
++ assert.strictEqual(await count("SELECT count(*) AS n FROM space_projects WHERE owner_subject = ? AND status = 'active'", [alice.subject]), 0);
++ assert.notStrictEqual((await t.get(`${slug}.openvibe.website`, '/')).status, 200, 'the address stops serving');
++ const conf = sent.find((c) => c.path === '/internal/account-deletions/del_01JZ00000000000000000SDE02/confirmations');
++ assert.deepStrictEqual([conf.body.erased.space_apps, conf.body.erased.space_projects], [1, 1]);
++ });
++
++ await check('a delivery without the Events signature is refused', async () => {
++ const r = await t.api('POST', '/internal/events', { json: { event: ev('evt_01JZ00000000000000000SE003', 'network.account.deleted', { deletion_id: 'del_x', subject: alice.subject }), seq: 1 } });
++ assert.strictEqual(r.status, 401);
++ });
++
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/stageb/boot.js b/test/stageb/boot.js
iindex 4b4b691..1afae75 100644
---- a/test/stageb/boot.js
++++ b/test/stageb/boot.js
@@@ -67,7 +67,7 @@ async function boot(opts = {}) {
Object.assign(process.env, env);
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
-- const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, spacesAiClient: opts.spacesAiClient });
++ const app = await createApp({ db, hostingConfig: config, now: clock.now, log: quiet, resolver, fetchImpl: opts.fetchImpl, indexnow: opts.indexnow, spacesAiClient: opts.spacesAiClient, accountSend: opts.accountSend });
await app.locals.auth.ensureKey();
built = { app, ctx: app.locals.hosting };
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });