openvibers/openvibe.run Commits
account-data: the consumer's route answers before the catch-all (#116)
Changes
@@@ -157,6 +157,11 @@ function createApp(opts = {}) {icons: [192, 512].flatMap((s) => [false, true].map((maskable) => ({ src: require('openvibe-shared/app-icons').url('run', s, { maskable }), sizes: `${s}x${s}`, type: 'image/png', purpose: maskable ? 'maskable' : 'any' }))),})));++ // The account export and deletion consumer (ADR-033, server/account-data.js): the two account topics, signed++ // and loopback only. It answers the raw body (the v2 signature covers it), so no body parser may run before it:++ // it is mounted here, before the catch-all below, which would otherwise answer every /internal/ request.++ if (opts.accountData) app.post('/internal/events', opts.accountData.consumer({ secrets: [config.events.secret].filter((s) => s.length >= 32), send: opts.accountSend || null }));++app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });return res.status(404).type('text/plain').send('Not found\n');@@@ -37,7 +37,7 @@ async function main() {const accountData = accountDataLib.create({ db, log: console });const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;-- const app = createApp({ config, db, valkey, registry });++ const app = createApp({ config, db, valkey, registry, accountData, accountSend });const { keys, outbox, stream, tickets, dispatch, indexnow, sessions } = app.locals;keys.start();const ticketKey = tickets.start();@@@ -52,7 +52,6 @@ async function main() {sessions.start();}-- app.post('/internal/events', accountData.consumer({ secrets: [config.events.secret].filter((s) => s.length >= 32), send: accountSend }));let accountSubscription = null;const server = app.listen(config.port, config.host, () => {console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);@@@ -3,10 +3,14 @@* ADR-033: Run's part of an account export and deletion. A person's open browser sessions are exported (their* settings and timing, never a ticket) and deleted; someone else's stay. The guard fails when a new table with a* person column is neither covered nor exempted with a reason.++ *++ * The consumer's route is checked too: POST /internal/events answers the delivery (a bad signature is refused),++ * so a route that never answers is caught here and not in production.*/const assert = require('assert');const { uncovered } = require('openvibe-sdk/account-data');const { testDb } = require('./helpers/db');++const { boot } = require('./helpers/app');const { TABLES, create } = require('../server/account-data');const A = 'usr_01JZ0000000000000000000AAA';@@@ -41,6 +45,12 @@ const B = 'usr_01JZ0000000000000000000BBB';assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000ADA']), 'user:deleted', 'the job stays, without its requester');assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000BQB']), `user:${B}`, 'Bo\'s job is untouched');assert.strictEqual(counts.retained.run_jobs_tombstoned, 1);-- console.log(' ✓ export, erase and the guard');++++ // The route answers: an unsigned delivery is refused (401), not lost (404) and not accepted.++ const t = await boot({ db: d.db, env: { RUN_EVENTS_SECRET: 's'.repeat(40) }, appOpts: { accountData } });++ const r = await t.call('POST', '/internal/events', { body: {}, token: null });++ assert.strictEqual(r.status, 401, `the consumer refuses an unsigned delivery: ${r.text}`);++ await t.close();++ console.log(' ✓ export, erase, the guard and the route');} finally { await d.close(); }})();
@@ -157,6 +157,11 @@ function createApp(opts = {}) {
@@ -157,6 +157,11 @@ function createApp(opts = {}) {
icons: [192, 512].flatMap((s) => [false, true].map((maskable) => ({ src: require('openvibe-shared/app-icons').url('run', s, { maskable }), sizes: `${s}x${s}`, type: 'image/png', purpose: maskable ? 'maskable' : 'any' }))),
icons: [192, 512].flatMap((s) => [false, true].map((maskable) => ({ src: require('openvibe-shared/app-icons').url('run', s, { maskable }), sizes: `${s}x${s}`, type: 'image/png', purpose: maskable ? 'maskable' : 'any' }))),
})));
})));
+ // The account export and deletion consumer (ADR-033, server/account-data.js): the two account topics, signed
+ // and loopback only. It answers the raw body (the v2 signature covers it), so no body parser may run before it:
+ // it is mounted here, before the catch-all below, which would otherwise answer every /internal/ request.
+ if (opts.accountData) app.post('/internal/events', opts.accountData.consumer({ secrets: [config.events.secret].filter((s) => s.length >= 32), send: opts.accountSend || null }));
+
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
return res.status(404).type('text/plain').send('Not found\n');
return res.status(404).type('text/plain').send('Not found\n');
@@ -37,7 +37,7 @@ async function main() {
@@ -37,7 +37,7 @@ async function main() {
const accountData = accountDataLib.create({ db, log: console });
const accountData = accountDataLib.create({ db, log: console });
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
const accountSend = config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret }) : null;
- const app = createApp({ config, db, valkey, registry });
+ const app = createApp({ config, db, valkey, registry, accountData, accountSend });
const { keys, outbox, stream, tickets, dispatch, indexnow, sessions } = app.locals;
const { keys, outbox, stream, tickets, dispatch, indexnow, sessions } = app.locals;
keys.start();
keys.start();
const ticketKey = tickets.start();
const ticketKey = tickets.start();
@@ -52,7 +52,6 @@ async function main() {
@@ -52,7 +52,6 @@ async function main() {
sessions.start();
sessions.start();
}
}
- app.post('/internal/events', accountData.consumer({ secrets: [config.events.secret].filter((s) => s.length >= 32), send: accountSend }));
let accountSubscription = null;
let accountSubscription = null;
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);
console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);
@@ -3,10 +3,14 @@
@@ -3,10 +3,14 @@
* ADR-033: Run's part of an account export and deletion. A person's open browser sessions are exported (their
* ADR-033: Run's part of an account export and deletion. A person's open browser sessions are exported (their
* settings and timing, never a ticket) and deleted; someone else's stay. The guard fails when a new table with a
* settings and timing, never a ticket) and deleted; someone else's stay. The guard fails when a new table with a
* person column is neither covered nor exempted with a reason.
* person column is neither covered nor exempted with a reason.
+ *
+ * The consumer's route is checked too: POST /internal/events answers the delivery (a bad signature is refused),
+ * so a route that never answers is caught here and not in production.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { uncovered } = require('openvibe-sdk/account-data');
const { uncovered } = require('openvibe-sdk/account-data');
const { testDb } = require('./helpers/db');
const { testDb } = require('./helpers/db');
+const { boot } = require('./helpers/app');
const { TABLES, create } = require('../server/account-data');
const { TABLES, create } = require('../server/account-data');
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
@@ -41,6 +45,12 @@ const B = 'usr_01JZ0000000000000000000BBB';
@@ -41,6 +45,12 @@ const B = 'usr_01JZ0000000000000000000BBB';
assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000ADA']), 'user:deleted', 'the job stays, without its requester');
assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000ADA']), 'user:deleted', 'the job stays, without its requester');
assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000BQB']), `user:${B}`, 'Bo\'s job is untouched');
assert.strictEqual(await d.db.value('SELECT requester FROM run_jobs WHERE id = $1', ['job_01JZ0000000000000000000BQB']), `user:${B}`, 'Bo\'s job is untouched');
assert.strictEqual(counts.retained.run_jobs_tombstoned, 1);
assert.strictEqual(counts.retained.run_jobs_tombstoned, 1);
- console.log(' ✓ export, erase and the guard');
+
+ // The route answers: an unsigned delivery is refused (401), not lost (404) and not accepted.
+ const t = await boot({ db: d.db, env: { RUN_EVENTS_SECRET: 's'.repeat(40) }, appOpts: { accountData } });
+ const r = await t.call('POST', '/internal/events', { body: {}, token: null });
+ assert.strictEqual(r.status, 401, `the consumer refuses an unsigned delivery: ${r.text}`);
+ await t.close();
+ console.log(' ✓ export, erase, the guard and the route');
} finally { await d.close(); }
} finally { await d.close(); }
})();
})();