openvibers/openvibe.vip Commits

Move VIP errors and shutdown onto openvibe-sdk/service (#5)

OP OpenVibers committed 2 Oct 2026 · 183a1c9a8e · 6 files, +188 −65

checks run by GitHub CI (test, test-pg) · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.VIP/pull/5

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 077f70d..2e27692 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
@@@ -1133,9 +1133,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.25.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
-- "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
++ "version": "0.26.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
++ "integrity": "sha512-+RK89ZG4WAKIRh89yr3ieIvnyJVNSOI+dsbBqGNcr9UHsJStdxtM75zSDq8cUNpBPQ1X6ElatZrjU9S3s0aEkg==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex d753060..8ecd167 100644
---- a/package.json
++++ b/package.json
@@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
},
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 6650ff6..6fde2fe 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -9,8 +9,8 @@
* cancellation are handed to OpenVibe.Billing, and entitlement truth is Billing's.
*/
const express = require('express');
--const { http } = require('openvibe-contracts');
--const { VipError, fail, userSubject, isUserSubject, entityRef, bool } = require('../util');
++const svc = require('openvibe-sdk/service');
++const { fail, userSubject, isUserSubject, entityRef, bool } = require('../util');
const CAP = {
planCreate: 'vip.plan.create',
@@@ -376,23 +376,16 @@ function v1Router({ domain, apiAuth, limits }) {
return r;
}
--/** Async-safe handler: VipError → problem+json; anything else → 500. */
--function wrap(fn) {
-- return async (req, res, next) => {
-- try {
-- const p = await fn(req, res, next);
-- if (p && typeof p.catch === 'function') p.catch((e) => sendError(req, res, e));
-- } catch (e) { sendError(req, res, e); }
-- };
--}
++/**
++ * Async-safe handler: VipError → problem+json; anything else → 500 vip.internal with 'internal error'.
++ * Built from openvibe-sdk/service (plan T1) with VIP's own options — the fallback code and detail, and
++ * `extra` nested as { details } below 500, are the kit's internalCode / internalDetail / extra: 'details'.
++ * The exported signatures stay (req, res, e), so no call site moves.
++ */
++const VIP_ERRORS = { name: 'VIP', extra: 'details', internalCode: 'vip.internal', internalDetail: 'internal error' };
--function sendError(req, res, e) {
-- if (res.headersSent) return;
-- if (e instanceof VipError) {
-- return http.sendProblem(res, e.status, e.code, { detail: e.detail || e.message, ctx: req.ov, extra: e.extra && e.status < 500 ? { details: e.extra } : undefined });
-- }
-- console.error('[VIP] unexpected error:', e);
-- return http.sendProblem(res, 500, 'vip.internal', { detail: 'internal error', ctx: req.ov });
--}
++const wrap = (fn) => svc.wrap(fn, VIP_ERRORS);
++
++const sendError = (req, res, e) => svc.sendError(res, req, e, undefined, VIP_ERRORS);
module.exports = { v1Router, wrap, sendError, CAP };
ddiff --git a/server/index.js b/server/index.js
iindex d891237..7d05761 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -10,40 +10,59 @@
*/
const { loadConfig } = require('./config');
const { createApp } = require('./app');
++const { gracefulStop } = require('openvibe-sdk/service');
--const config = loadConfig();
--if (config.isProduction && !config.formSecret) {
-- console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
-- process.exit(1);
++/**
++ * The process stop (openvibe-sdk/service, plan T1): the job timers and relays stop taking new work
++ * (they ran before the old server.close), the HTTP drain runs (defaults 4000/5000), then the domain
++ * database closes; past the deadline the process exits 0, as the hand-rolled 5 s timer did. Exported
++ * so a test can inject `exit` and `signals: false`.
++ */
++function createLifecycle({ server, app, timers = [], exit, signals } = {}) {
++ const { domain, keys, outbox, metrics } = app.locals;
++ return gracefulStop({
++ name: 'VIP', server, deadlineExitCode: 0, exit, signals,
++ stop: [
++ () => timers.forEach(clearInterval),
++ () => outbox.stop(),
++ () => keys.stop(),
++ () => { if (metrics && metrics.stop) metrics.stop(); },
++ ],
++ close: [() => domain.db.close()],
++ });
}
--(async () => {
--const app = await createApp({ config });
--const { domain, keys, outbox, metrics } = app.locals;
--keys.start();
--
--const timers = [];
--if (config.jobs.enabled) {
-- const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
-- every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
-- every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
-- outbox.start();
++
++async function start() {
++ const config = loadConfig();
++ if (config.isProduction && !config.formSecret) {
++ console.error('[VIP] VIP_FORM_SECRET must be set in production (it signs the pages\' anti-forgery tokens)');
++ process.exit(1);
++ }
++
++ const app = await createApp({ config });
++ const { domain, keys, outbox } = app.locals;
++ keys.start();
++
++ const timers = [];
++ if (config.jobs.enabled) {
++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[VIP] job:', e.message)); }, ms); t.unref(); timers.push(t); };
++ every(config.jobs.refreshIntervalMs, async () => await domain.entitlements.refreshDue());
++ every(6 * 3600 * 1000, async () => await outbox.outbox.prune());
++ outbox.start();
++ }
++
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);
++ console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
++ });
++ server.keepAliveTimeout = 65_000;
++
++ createLifecycle({ server, app, timers });
++ return { server, app };
}
--const server = app.listen(config.port, config.host, () => {
-- console.log(`[VIP] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${domain.db.store})`);
-- console.log(`[VIP] billing ${config.billing.url}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; billing events ${config.events.webhookSecrets.length ? 'accepted' : 'not accepted (VIP_EVENTS_SECRET unset)'}`);
--});
--server.keepAliveTimeout = 65_000;
--
--function shutdown(signal) {
-- console.log(`[VIP] ${signal} — closing`);
-- timers.forEach(clearInterval);
-- outbox.stop();
-- keys.stop();
-- if (metrics && metrics.stop) metrics.stop();
-- server.close(() => { Promise.resolve().then(() => domain.db.close()).catch(() => {}).finally(() => process.exit(0)); });
-- setTimeout(() => process.exit(0), 5000).unref();
++if (require.main === module) {
++ start().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });
}
--process.on('SIGTERM', () => shutdown('SIGTERM'));
--process.on('SIGINT', () => shutdown('SIGINT'));
--})().catch((err) => { console.error('[VIP] failed to start:', err); process.exit(1); });
++
++module.exports = { start, createLifecycle };
ddiff --git a/server/util.js b/server/util.js
iindex 2370231..1c04b4d 100644
---- a/server/util.js
++++ b/server/util.js
@@@ -2,16 +2,12 @@
/** Small shared pieces: ids, time, the error type, input checks. */
const { ids, validate } = require('openvibe-contracts');
++const { createServiceError } = require('openvibe-sdk/service');
--class VipError extends Error {
-- constructor(status, code, detail, extra) {
-- super(detail || code);
-- this.status = status;
-- this.code = code;
-- this.detail = detail;
-- this.extra = extra;
-- }
--}
++// openvibe-sdk/service (plan T1): the kit's error class with VIP's name. Same constructor
++// (status, code, detail, extra) and same fields, so `instanceof VipError`, fail() and every
++// call site stay exactly as they were.
++const VipError = createServiceError('VipError');
function fail(status, code, detail, extra) { throw new VipError(status, code, detail, extra); }
ddiff --git a/test/service-kit.test.js b/test/service-kit.test.js
nnew file mode 100644
iindex 0000000..ecdc756
---- /dev/null
++++ b/test/service-kit.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++/**
++ * openvibe-sdk/service (plan T1): VIP's error half is the kit's wrap/sendError with VIP's options, so a
++ * VipError answers its own status/code/detail with `extra` nested as { details } below 500, and anything
++ * else answers 500 vip.internal with 'internal error'. The kit's JSON parser (wired the way a service
++ * adopts it) answers 413 request.too_large / 400 request.invalid_json / 415 request.unsupported_encoding;
++ * VIP's own app keeps express.json, so its current codes are pinned unchanged. The entry point's graceful
++ * stop runs its stop and close steps in order, then exits 0.
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const svc = require('openvibe-sdk/service');
++const { boot, harness } = require('./helpers/app');
++const { wrap } = require('../server/api/v1');
++const { VipError } = require('../server/util');
++
++const { test, run } = harness('service-kit');
++
++(async () => {
++ const t = await boot();
++
++ // A service-shaped app on the kit's parser + VIP's wrap, the way the recipe wires it.
++ const app = express();
++ app.post('/echo', svc.jsonBody({ limit: '64kb' }), wrap(async (req, res) => { res.json({ ok: true }); }));
++ app.post('/refuse', wrap(async () => { throw new VipError(422, 'vip.test_refusal', 'that is not allowed', { field: 'provider' }); }));
++ app.post('/gone', wrap(async () => { throw new VipError(503, 'vip.billing_unavailable', 'Billing is not answering right now; nothing was charged'); }));
++ app.post('/boom', wrap(async () => { throw new Error('boom'); }));
++ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const post = (p, body, headers = {}) => fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body });
++ const read = async (r) => { const text = await r.text(); return { status: r.status, headers: r.headers, text, json: JSON.parse(text) }; };
++
++ test('the kit parser: a JSON body over 64 kB is 413 request.too_large', async () => {
++ const r = await read(await post('/echo', JSON.stringify({ padding: 'y'.repeat(70 * 1024) })));
++ assert.strictEqual(r.status, 413, r.text);
++ assert.strictEqual(r.json.code, 'request.too_large');
++ });
++
++ test('the kit parser: malformed JSON is 400 request.invalid_json', async () => {
++ const r = await read(await post('/echo', '{oops'));
++ assert.strictEqual(r.status, 400, r.text);
++ assert.strictEqual(r.json.code, 'request.invalid_json');
++ });
++
++ test('the kit parser: an unreadable Content-Encoding is 415 request.unsupported_encoding', async () => {
++ const r = await read(await post('/echo', '{}', { 'content-encoding': 'xz' }));
++ assert.strictEqual(r.status, 415, r.text);
++ assert.strictEqual(r.json.code, 'request.unsupported_encoding');
++ });
++
++ test('VIP\'s app keeps its own parser answers: over 64 kB 413 request.too_large, malformed 400 request.malformed_json', async () => {
++ const big = await read(await fetch(`${t.base}/api/v1/plans`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ padding: 'y'.repeat(70 * 1024) }) }));
++ assert.strictEqual(big.status, 413, big.text);
++ assert.strictEqual(big.json.code, 'request.too_large');
++ const bad = await read(await fetch(`${t.base}/api/v1/plans`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }));
++ assert.strictEqual(bad.status, 400, bad.text);
++ assert.strictEqual(bad.json.code, 'request.malformed_json');
++ });
++
++ test('a VipError answers its own status/code/detail and nests extra as { details } below 500', async () => {
++ const r = await read(await post('/refuse', '{}'));
++ assert.strictEqual(r.status, 422, r.text);
++ assert.strictEqual(r.json.code, 'vip.test_refusal');
++ assert.strictEqual(r.json.detail, 'that is not allowed');
++ assert.deepStrictEqual(r.json.details, { field: 'provider' });
++ assert.strictEqual(r.json.error, 'that is not allowed');
++ });
++
++ test('a 503 VipError keeps its detail (the fallback detail is only for an unexpected 500)', async () => {
++ const r = await read(await post('/gone', '{}'));
++ assert.strictEqual(r.status, 503, r.text);
++ assert.strictEqual(r.json.code, 'vip.billing_unavailable');
++ assert.strictEqual(r.json.detail, 'Billing is not answering right now; nothing was charged');
++ assert.strictEqual(r.json.details, undefined);
++ });
++
++ test('an unexpected throw answers 500 vip.internal with \'internal error\'', async () => {
++ const r = await read(await post('/boom', '{}'));
++ assert.strictEqual(r.status, 500, r.text);
++ assert.strictEqual(r.json.code, 'vip.internal');
++ assert.strictEqual(r.json.detail, 'internal error');
++ });
++
++ test('the entry point stop runs its stop and close steps in order, then exits 0', async () => {
++ const steps = [];
++ const spy = (obj, method, label) => { const orig = obj[method].bind(obj); obj[method] = (...a) => { steps.push(label); return orig(...a); }; };
++ spy(t.app.locals.outbox, 'stop', 'outbox.stop');
++ spy(t.app.locals.keys, 'stop', 'keys.stop');
++ const stopsMetrics = !!(t.app.locals.metrics && typeof t.app.locals.metrics.stop === 'function');
++ if (stopsMetrics) spy(t.app.locals.metrics, 'stop', 'metrics.stop');
++ spy(t.app.locals.domain.db, 'close', 'domain.db.close');
++
++ const stopServer = http.createServer(t.app);
++ await new Promise((resolve) => stopServer.listen(0, '127.0.0.1', resolve));
++
++ const { createLifecycle } = require('../server/index');
++ const exits = [];
++ const lifecycle = createLifecycle({ server: stopServer, app: t.app, timers: [], exit: (code) => exits.push(code), signals: false });
++ const code = await lifecycle.stop('SIGTERM');
++
++ const expected = ['outbox.stop', 'keys.stop'];
++ if (stopsMetrics) expected.push('metrics.stop');
++ expected.push('domain.db.close');
++ assert.strictEqual(code, 0);
++ assert.deepStrictEqual(exits, [0]);
++ assert.deepStrictEqual(steps, expected);
++ assert.strictEqual(stopServer.listening, false);
++ });
++
++ await run().finally(async () => {
++ server.close();
++ try { await t.close(); } catch { /* the lifecycle above already closed the db */ }
++ });
++})();