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)
Changes
@@@ -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@@@ -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 |@@@ -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; }nnew file mode 100644@@@ -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++);nnew file mode 100644@@@ -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 };@@@ -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: {@@@ -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@@@ -15,7 +15,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdkconst 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-sdkapp.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-sdkgracefulStop({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 () => {nnew file mode 100644@@@ -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); });@@@ -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)); });
@@ -43,6 +43,8 @@ DATABASE_DIRECT_URL=
@@ -43,6 +43,8 @@ DATABASE_DIRECT_URL=
SPACE_PUBLIC_HOSTING=off
SPACE_PUBLIC_HOSTING=off
# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
# Spaces (server/spaces/, docs/spaces.md): the AI builder and spaces' AI go through OpenVibe.AI (space.* workflows).
#SPACE_AI=on
#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
#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
#SPACE_SPACES_PER_OWNER=25
#SPACE_SPACES_PER_OWNER=25
#SPACE_AI_BUILDS_PER_DAY=20
#SPACE_AI_BUILDS_PER_DAY=20
@@ -37,7 +37,11 @@ OpenVibe.Host is the hosting price comparison; its copy of the engine is being r
@@ -37,7 +37,11 @@ OpenVibe.Host is the hosting price comparison; its copy of the engine is being r
## Depends on
## Depends on
- **OpenVibe.Network:** sign-in (OAuth with PKCE), tokens and their keys, project membership.
- **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.Media:** tenant objects, when `SPACE_OBJECT_STORE=media`.
- **OpenVibe.Billing:** storage readings, when `SPACE_BILLING_INTERVAL_MS` is on.
- **OpenVibe.Billing:** storage readings, when `SPACE_BILLING_INTERVAL_MS` is on.
- **OpenVibe.Services:** reads the resource index and sends control requests.
- **OpenVibe.Services:** reads the resource index and sends control requests.
@@ -97,6 +101,7 @@ The transactional outbox emits `space.deploy.created`, `space.deploy.activated`,
@@ -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/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/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.
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).
@@ -105,6 +110,7 @@ Copy `.env.example` to `.env`. Production uses `/etc/openvibe/space.env` (0600).
| Variable | Default | Purpose |
| Variable | Default | Purpose |
| --- | --- | --- |
| --- | --- | --- |
| `SPACE_PUBLIC_HOSTING` | `off` | Staff-only project creation (and publishing spaces) until launch |
| `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_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_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 |
| `SPACE_SITES_DOMAIN` | `openvibe.website` | Tenant names |
@@ -144,6 +144,21 @@ server {
@@ -144,6 +144,21 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
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
# ── 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. ──
# refuses any request carrying X-Forwarded-For, which every location above sets. ──
location ~* ^/metrics(/|$) { return 404; }
location ~* ^/metrics(/|$) { return 404; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -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
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -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 };
@@ -89,6 +89,21 @@ async function createApp(opts = {}) {
@@ -89,6 +89,21 @@ async function createApp(opts = {}) {
hosting.mountDispatch(app);
hosting.mountDispatch(app);
app.get('/metrics', sharedMetrics.metricsHandler(registry));
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({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
@@ -89,6 +89,9 @@ function load(env = process.env) {
@@ -89,6 +89,9 @@ function load(env = process.env) {
events: {
events: {
url: trim(env.EVENTS_URL || ''),
url: trim(env.EVENTS_URL || ''),
intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
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
// 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
// keeps (server/billing.js). SPACE_BILLING_INTERVAL_MS=0 (the default) starts nothing; Media mode emits no
@@ -15,7 +15,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -15,7 +15,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
const app = await createApp();
const app = await createApp();
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Space] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
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;
server.keepAliveTimeout = 65_000;
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// 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
@@ -25,6 +25,14 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
app.locals.hosting.worker.start();
app.locals.hosting.worker.start();
app.locals.hosting.outbox.start();
app.locals.hosting.outbox.start();
app.locals.hosting.billing.start(); // off unless SPACE_BILLING_INTERVAL_MS > 0
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) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the server stops taking connections, closes idle keep-alive ones (it keeps them 65 s
// 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
@@ -34,7 +42,7 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
gracefulStop({
gracefulStop({
name: 'Space', server,
name: 'Space', server,
drainMs: 4000, deadlineMs: 5000, deadlineExitCode: 1,
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: [
close: [
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => { if (telemetryRetention) await telemetryRetention.stop(); await telemetry.stop(); },
async () => {
async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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); });
@@ -67,7 +67,7 @@ async function boot(opts = {}) {
@@ -67,7 +67,7 @@ async function boot(opts = {}) {
Object.assign(process.env, env);
Object.assign(process.env, env);
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
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();
await app.locals.auth.ensureKey();
built = { app, ctx: app.locals.hosting };
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)); });
server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });