openvibers/openvibe.events Commits
Subscription secret rotation: POST /api/v1/subscriptions/:id/rotate-secret (the consumer's own subscription; overlap_s default 1 day, max 7) and scripts/rotate-subscription-secret.js for operators (writes the new secret into the consumer's env file, never prints it). During the overlap every delivery's X-OpenVibe-Signature-V2 carries a v2 for the new and the old secret, so the consumer switches without missing one (openvibe-sdk accepts any matching v2). delivery test
Changes
nnew file mode 100755@@@ -0,0 +1,58 @@++#!/usr/bin/env node++'use strict';++/**++ * Operator rotation of a first-party subscription's secret, without the value ever being printed.++ *++ * node scripts/rotate-subscription-secret.js --db <events.db> --subscription <sub_…>++ * --env-file </etc/openvibe/<consumer>.env> --env-var <NAME> [--overlap-s 86400] [--apply]++ *++ * With --apply it (1) sets a new random secret on the subscription, keeping the old one signing next to it++ * for --overlap-s (default 1 day), and (2) writes NAME=<new secret> into the consumer's env file (replacing++ * the line, or appending it; a .bak-<time> copy is kept). Then restart the consumer: during the overlap++ * every delivery carries both signatures, so it verifies with either secret and nothing is missed.++ * Without --apply it only checks that the subscription and the env file exist and says what it would do.++ * Run as root (the env file is root-owned); the database is opened by path.++ */++const fs = require('fs');++const crypto = require('crypto');++const Database = require('better-sqlite3');++++const args = process.argv.slice(2);++const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };++const apply = args.includes('--apply');++const dbPath = opt('--db');++const subId = opt('--subscription');++const envFile = opt('--env-file');++const envVar = opt('--env-var');++const overlapS = Number(opt('--overlap-s', '86400'));++++function fail(msg) { console.error(`rotate-subscription-secret: ${msg}`); process.exit(1); }++if (!dbPath || !subId || !envFile || !envVar) fail('--db, --subscription, --env-file and --env-var are required');++if (!/^[A-Z][A-Z0-9_]{1,63}$/.test(envVar)) fail('--env-var must be an env variable name');++if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) fail('--overlap-s is 0..604800');++if (!fs.existsSync(envFile)) fail(`${envFile} does not exist`);++++const db = new Database(dbPath, { fileMustExist: true });++const sub = db.prepare('SELECT id, consumer, topic_pattern, project_id FROM subscriptions WHERE id = ?').get(subId);++if (!sub) fail(`no subscription ${subId}`);++if (sub.project_id) fail('developer-app subscriptions rotate through the API (POST /api/v1/subscriptions/:id/rotate-secret)');++const cols = new Set(db.prepare('PRAGMA table_info(subscriptions)').all().map((c) => c.name));++if (!cols.has('previous_secret')) fail('this Events database predates rotation; deploy Events first (it adds the columns at boot)');++++const lines = fs.readFileSync(envFile, 'utf8').split('\n');++const has = lines.some((l) => l.startsWith(`${envVar}=`));++console.log(`subscription ${sub.id} (${sub.consumer}, ${sub.topic_pattern}); ${envFile}: ${envVar} ${has ? 'will be replaced' : 'will be appended'}; overlap ${overlapS}s`);++if (!apply) { console.log('dry run: nothing changed (add --apply)'); process.exit(0); }++++const secret = `whsec_${crypto.randomBytes(32).toString('hex')}`;++const now = Date.now();++fs.copyFileSync(envFile, `${envFile}.bak-${new Date(now).toISOString().replace(/[:.]/g, '-')}`);++db.transaction(() => {++ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')++ .run(now + overlapS * 1000, secret, now, sub.id);++})();++let out;++if (has) out = lines.map((l) => (l.startsWith(`${envVar}=`) ? `${envVar}=${secret}` : l));++else { out = lines.slice(); while (out.length && out[out.length - 1] === '') out.pop(); out.push(`${envVar}=${secret}`, ''); }++fs.writeFileSync(envFile, out.join('\n'), { mode: fs.statSync(envFile).mode });++console.log(`rotated: both secrets sign until ${new Date(now + overlapS * 1000).toISOString()}; ${envVar} updated. Restart ${sub.consumer} now.`);@@@ -139,6 +139,25 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {if (sub) res.json(subscriptionView(sub));});++ // POST /api/v1/subscriptions/:id/rotate-secret { secret?, overlap_s? } -> the new secret (shown once). The old++ // secret keeps signing next to it for overlap_s (default 1 day, at most 7), so the consumer can switch without++ // missing a delivery.++ router.post('/api/v1/subscriptions/:id/rotate-secret', guard, express.json({ limit: '4kb' }), (req, res) => {++ const sub = own(req, res);++ if (!sub) return;++ const b = req.body || {};++ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {++ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx: req.ov });++ }++ const overlapS = b.overlap_s === undefined ? 86400 : Number(b.overlap_s);++ if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) {++ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'overlap_s is 0..604800', ctx: req.ov });++ }++ const secret = b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`;++ const out = store.rotateSubscriptionSecret(sub.id, secret, overlapS * 1000);++ res.json({ ...subscriptionView(out), secret, previous_secret_valid_until: new Date(out.previous_secret_until).toISOString() });++ });++for (const [action, enabled] of [['disable', false], ['enable', true]]) {router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {if (enabled && subscribeRevoked(req, res)) return;@@@ -118,7 +118,7 @@ const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for acceconst ADDED_COLUMNS = {events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0'],['redacted_at', 'INTEGER'], ['redacted_by', 'TEXT'], ['redacts', 'INTEGER NOT NULL DEFAULT 0']],-- subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"]],++ subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['previous_secret', 'TEXT'], ['previous_secret_until', 'INTEGER']],};function migrate(db) {@@@ -488,6 +488,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}function countSubscriptions(consumer) {return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer).n;}++ /** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */++ function rotateSubscriptionSecret(id, secret, overlapMs) {++ const now = clock.now();++ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')++ .run(now + Math.max(0, overlapMs), secret, now, id);++ return getSubscription(id);++ }++function setSubscriptionEnabled(id, enabled) {db.prepare('UPDATE subscriptions SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, clock.now(), id);return getSubscription(id);@@@ -605,7 +613,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}return {db, insertBatch, redact, getEvent, revokedAt, lastSeq, oldestSeq, firstSeqSince, scan,-- createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled,++ createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,getCheckpoint, setCheckpoint, prune, ping,};@@@ -21,6 +21,19 @@*/const crypto = require('crypto');const { sign, signV2 } = require('../lib/client');++++/**++ * The v2 header for a delivery. During a secret rotation's overlap it carries a second v2= signature made++ * with the previous secret, so a consumer still configured with either secret accepts it (openvibe-sdk's++ * verifyDeliveryV2 checks every v2= value).++ */++function signatureV2(sub, body, timestamp, nowMs) {++ const header = signV2(body, sub.secret, timestamp);++ if (sub.previous_secret && Number(sub.previous_secret_until) > nowMs) {++ return `${header},v2=${signV2(body, sub.previous_secret, timestamp).split('v2=')[1]}`;++ }++ return header;++}const { rowToEnvelope } = require('./store');const { checkEndpoint } = require('./endpoints');const { createGuardedPost } = require('./egress');@@@ -65,7 +78,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI'X-OpenVibe-Hops': String(row.hops),'X-OpenVibe-Signature': sign(body, sub.secret),'X-OpenVibe-Timestamp': String(timestamp),-- 'X-OpenVibe-Signature-V2': signV2(body, sub.secret, timestamp),++ 'X-OpenVibe-Signature-V2': signatureV2(sub, body, timestamp, clock.now()),traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,};let res;@@@ -251,6 +251,29 @@ t('the real loop delivers without drain()', async () => {await stub.close();});++t('secret rotation: both secrets sign during the overlap, only the new one after it', async () => {++ const stub = await subscriber();++ const sub = (await subscribe(media, { topic_pattern: 'live.rotate.*', endpoint: stub.url })).body;++ const r = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: media, body: { overlap_s: 3600 } });++ assert.strictEqual(r.status, 200);++ assert.ok(r.body.secret && r.body.secret !== sub.secret && r.body.previous_secret_valid_until);++ assert.ok(!('previous_secret' in r.body), 'the old secret is never echoed');++ const other = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: serviceToken('someone', ['events.subscription.manage']), body: {} });++ assert.strictEqual(other.status, 404, 'only the consumer rotates its own subscription');++ await publish(envelope('live', { event_type: 'live.rotate.one' }));++ await h.worker.drain();++ let call = stub.calls.pop();++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'the old secret still verifies');++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }), 'the new secret verifies');++ h.clock.advance(3601 * 1000);++ await publish(envelope('live', { event_type: 'live.rotate.two' }));++ await h.worker.drain();++ call = stub.calls.pop();++ assert.ok(!verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'after the overlap the old secret no longer verifies');++ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }));++ await stub.close();++});++t('stop', async () => { await h.stop(); });t.run();
new file mode 100755
new file mode 100755
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Operator rotation of a first-party subscription's secret, without the value ever being printed.
+ *
+ * node scripts/rotate-subscription-secret.js --db <events.db> --subscription <sub_…>
+ * --env-file </etc/openvibe/<consumer>.env> --env-var <NAME> [--overlap-s 86400] [--apply]
+ *
+ * With --apply it (1) sets a new random secret on the subscription, keeping the old one signing next to it
+ * for --overlap-s (default 1 day), and (2) writes NAME=<new secret> into the consumer's env file (replacing
+ * the line, or appending it; a .bak-<time> copy is kept). Then restart the consumer: during the overlap
+ * every delivery carries both signatures, so it verifies with either secret and nothing is missed.
+ * Without --apply it only checks that the subscription and the env file exist and says what it would do.
+ * Run as root (the env file is root-owned); the database is opened by path.
+ */
+const fs = require('fs');
+const crypto = require('crypto');
+const Database = require('better-sqlite3');
+
+const args = process.argv.slice(2);
+const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const apply = args.includes('--apply');
+const dbPath = opt('--db');
+const subId = opt('--subscription');
+const envFile = opt('--env-file');
+const envVar = opt('--env-var');
+const overlapS = Number(opt('--overlap-s', '86400'));
+
+function fail(msg) { console.error(`rotate-subscription-secret: ${msg}`); process.exit(1); }
+if (!dbPath || !subId || !envFile || !envVar) fail('--db, --subscription, --env-file and --env-var are required');
+if (!/^[A-Z][A-Z0-9_]{1,63}$/.test(envVar)) fail('--env-var must be an env variable name');
+if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) fail('--overlap-s is 0..604800');
+if (!fs.existsSync(envFile)) fail(`${envFile} does not exist`);
+
+const db = new Database(dbPath, { fileMustExist: true });
+const sub = db.prepare('SELECT id, consumer, topic_pattern, project_id FROM subscriptions WHERE id = ?').get(subId);
+if (!sub) fail(`no subscription ${subId}`);
+if (sub.project_id) fail('developer-app subscriptions rotate through the API (POST /api/v1/subscriptions/:id/rotate-secret)');
+const cols = new Set(db.prepare('PRAGMA table_info(subscriptions)').all().map((c) => c.name));
+if (!cols.has('previous_secret')) fail('this Events database predates rotation; deploy Events first (it adds the columns at boot)');
+
+const lines = fs.readFileSync(envFile, 'utf8').split('\n');
+const has = lines.some((l) => l.startsWith(`${envVar}=`));
+console.log(`subscription ${sub.id} (${sub.consumer}, ${sub.topic_pattern}); ${envFile}: ${envVar} ${has ? 'will be replaced' : 'will be appended'}; overlap ${overlapS}s`);
+if (!apply) { console.log('dry run: nothing changed (add --apply)'); process.exit(0); }
+
+const secret = `whsec_${crypto.randomBytes(32).toString('hex')}`;
+const now = Date.now();
+fs.copyFileSync(envFile, `${envFile}.bak-${new Date(now).toISOString().replace(/[:.]/g, '-')}`);
+db.transaction(() => {
+ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')
+ .run(now + overlapS * 1000, secret, now, sub.id);
+})();
+let out;
+if (has) out = lines.map((l) => (l.startsWith(`${envVar}=`) ? `${envVar}=${secret}` : l));
+else { out = lines.slice(); while (out.length && out[out.length - 1] === '') out.pop(); out.push(`${envVar}=${secret}`, ''); }
+fs.writeFileSync(envFile, out.join('\n'), { mode: fs.statSync(envFile).mode });
+console.log(`rotated: both secrets sign until ${new Date(now + overlapS * 1000).toISOString()}; ${envVar} updated. Restart ${sub.consumer} now.`);
@@ -139,6 +139,25 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
@@ -139,6 +139,25 @@ function subscriptionsRouter({ config, store, auth, dnsLookup }) {
if (sub) res.json(subscriptionView(sub));
if (sub) res.json(subscriptionView(sub));
});
});
+ // POST /api/v1/subscriptions/:id/rotate-secret { secret?, overlap_s? } -> the new secret (shown once). The old
+ // secret keeps signing next to it for overlap_s (default 1 day, at most 7), so the consumer can switch without
+ // missing a delivery.
+ router.post('/api/v1/subscriptions/:id/rotate-secret', guard, express.json({ limit: '4kb' }), (req, res) => {
+ const sub = own(req, res);
+ if (!sub) return;
+ const b = req.body || {};
+ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {
+ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx: req.ov });
+ }
+ const overlapS = b.overlap_s === undefined ? 86400 : Number(b.overlap_s);
+ if (!Number.isFinite(overlapS) || overlapS < 0 || overlapS > 7 * 86400) {
+ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'overlap_s is 0..604800', ctx: req.ov });
+ }
+ const secret = b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`;
+ const out = store.rotateSubscriptionSecret(sub.id, secret, overlapS * 1000);
+ res.json({ ...subscriptionView(out), secret, previous_secret_valid_until: new Date(out.previous_secret_until).toISOString() });
+ });
+
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
for (const [action, enabled] of [['disable', false], ['enable', true]]) {
router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
if (enabled && subscribeRevoked(req, res)) return;
if (enabled && subscribeRevoked(req, res)) return;
@@ -118,7 +118,7 @@ const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for acce
@@ -118,7 +118,7 @@ const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for acce
const ADDED_COLUMNS = {
const ADDED_COLUMNS = {
events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0'],
events: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['size_bytes', 'INTEGER NOT NULL DEFAULT 0'],
['redacted_at', 'INTEGER'], ['redacted_by', 'TEXT'], ['redacts', 'INTEGER NOT NULL DEFAULT 0']],
['redacted_at', 'INTEGER'], ['redacted_by', 'TEXT'], ['redacts', 'INTEGER NOT NULL DEFAULT 0']],
- subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"]],
+ subscriptions: [['project_id', 'TEXT'], ['env', "TEXT NOT NULL DEFAULT 'production'"], ['previous_secret', 'TEXT'], ['previous_secret_until', 'INTEGER']],
};
};
function migrate(db) {
function migrate(db) {
@@ -488,6 +488,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -488,6 +488,14 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
function countSubscriptions(consumer) {
function countSubscriptions(consumer) {
return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer).n;
return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer).n;
}
}
+ /** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
+ function rotateSubscriptionSecret(id, secret, overlapMs) {
+ const now = clock.now();
+ db.prepare('UPDATE subscriptions SET previous_secret = secret, previous_secret_until = ?, secret = ?, updated_at = ? WHERE id = ?')
+ .run(now + Math.max(0, overlapMs), secret, now, id);
+ return getSubscription(id);
+ }
+
function setSubscriptionEnabled(id, enabled) {
function setSubscriptionEnabled(id, enabled) {
db.prepare('UPDATE subscriptions SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, clock.now(), id);
db.prepare('UPDATE subscriptions SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, clock.now(), id);
return getSubscription(id);
return getSubscription(id);
@@ -605,7 +613,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
@@ -605,7 +613,7 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}
return {
return {
db, insertBatch, redact, getEvent, revokedAt, lastSeq, oldestSeq, firstSeqSince, scan,
db, insertBatch, redact, getEvent, revokedAt, lastSeq, oldestSeq, firstSeqSince, scan,
- createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled,
+ createSubscription, getSubscription, listSubscriptions, countSubscriptions, countProjectSubscriptions, setSubscriptionEnabled, rotateSubscriptionSecret,
dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
getCheckpoint, setCheckpoint, prune, ping,
getCheckpoint, setCheckpoint, prune, ping,
};
};
@@ -21,6 +21,19 @@
@@ -21,6 +21,19 @@
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
const { sign, signV2 } = require('../lib/client');
const { sign, signV2 } = require('../lib/client');
+
+/**
+ * The v2 header for a delivery. During a secret rotation's overlap it carries a second v2= signature made
+ * with the previous secret, so a consumer still configured with either secret accepts it (openvibe-sdk's
+ * verifyDeliveryV2 checks every v2= value).
+ */
+function signatureV2(sub, body, timestamp, nowMs) {
+ const header = signV2(body, sub.secret, timestamp);
+ if (sub.previous_secret && Number(sub.previous_secret_until) > nowMs) {
+ return `${header},v2=${signV2(body, sub.previous_secret, timestamp).split('v2=')[1]}`;
+ }
+ return header;
+}
const { rowToEnvelope } = require('./store');
const { rowToEnvelope } = require('./store');
const { checkEndpoint } = require('./endpoints');
const { checkEndpoint } = require('./endpoints');
const { createGuardedPost } = require('./egress');
const { createGuardedPost } = require('./egress');
@@ -65,7 +78,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
@@ -65,7 +78,7 @@ function createWorker({ store, config, clock = { now: () => Date.now() }, fetchI
'X-OpenVibe-Hops': String(row.hops),
'X-OpenVibe-Hops': String(row.hops),
'X-OpenVibe-Signature': sign(body, sub.secret),
'X-OpenVibe-Signature': sign(body, sub.secret),
'X-OpenVibe-Timestamp': String(timestamp),
'X-OpenVibe-Timestamp': String(timestamp),
- 'X-OpenVibe-Signature-V2': signV2(body, sub.secret, timestamp),
+ 'X-OpenVibe-Signature-V2': signatureV2(sub, body, timestamp, clock.now()),
traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
};
};
let res;
let res;
@@ -251,6 +251,29 @@ t('the real loop delivers without drain()', async () => {
@@ -251,6 +251,29 @@ t('the real loop delivers without drain()', async () => {
await stub.close();
await stub.close();
});
});
+t('secret rotation: both secrets sign during the overlap, only the new one after it', async () => {
+ const stub = await subscriber();
+ const sub = (await subscribe(media, { topic_pattern: 'live.rotate.*', endpoint: stub.url })).body;
+ const r = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: media, body: { overlap_s: 3600 } });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.body.secret && r.body.secret !== sub.secret && r.body.previous_secret_valid_until);
+ assert.ok(!('previous_secret' in r.body), 'the old secret is never echoed');
+ const other = await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/rotate-secret`, { token: serviceToken('someone', ['events.subscription.manage']), body: {} });
+ assert.strictEqual(other.status, 404, 'only the consumer rotates its own subscription');
+ await publish(envelope('live', { event_type: 'live.rotate.one' }));
+ await h.worker.drain();
+ let call = stub.calls.pop();
+ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'the old secret still verifies');
+ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }), 'the new secret verifies');
+ h.clock.advance(3601 * 1000);
+ await publish(envelope('live', { event_type: 'live.rotate.two' }));
+ await h.worker.drain();
+ call = stub.calls.pop();
+ assert.ok(!verifyDeliveryV2(call.rawBody, call.headers, sub.secret, { now: h.clock.now() }), 'after the overlap the old secret no longer verifies');
+ assert.ok(verifyDeliveryV2(call.rawBody, call.headers, r.body.secret, { now: h.clock.now() }));
+ await stub.close();
+});
+
t('stop', async () => { await h.stop(); });
t('stop', async () => { await h.stop(); });
t.run();
t.run();