openvibers/openvibe.blog Commits

The changelog's GitHub token: CHANGELOG_GITHUB_TOKEN, else the network's (admin → Settings → GitHub), asked of Network's /internal/integrations/github-token with Blog's service token (network.integration.github.read) and kept ten minutes; else anonymous. A 401 from GitHub asks again; stats name the source, never the value

OP OpenVibers committed 24 Sept 2026 · ea6b788b13 · 2 files, +83 −5


Changes

ddiff --git a/server/changelog.js b/server/changelog.js
iindex d505a3a..55f6bfb 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -26,6 +26,7 @@
* shipped" shows, with the latest patch notes post to link to.
*/
const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
const GITHUB = 'https://api.github.com';
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
@@@ -124,10 +125,43 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
if (!res.ok) throw Object.assign(new Error(`${url.replace(/\?.*$/, '')} answered ${res.status}`), { status: res.status });
return res.json();
}
-- const gh = (path) => getJson(`${GITHUB}${path}`, { headers: {
-- Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Blog changelog (+https://openvibe.blog)',
-- ...(c.githubToken ? { Authorization: `Bearer ${c.githubToken}` } : {}),
-- } });
++ // The GitHub token: CHANGELOG_GITHUB_TOKEN, else the network's (admin → Settings → GitHub), asked of
++ // Network with Blog's service token (network.integration.github.read) and kept ten minutes; else
++ // anonymous (60 requests an hour, shared by the host).
++ const TOKEN_TTL_MS = 10 * 60 * 1000;
++ const networkTokens = !c.githubToken && config.oauth && config.oauth.clientSecret && config.networkInternalUrl
++ ? serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.integration.github.read', fetchImpl })
++ : null;
++ let cachedToken = { value: null, until: 0, source: c.githubToken ? 'env' : 'none' };
++ async function githubToken() {
++ if (c.githubToken) return c.githubToken;
++ if (!networkTokens) return null;
++ if (nowMs() < cachedToken.until) return cachedToken.value;
++ try {
++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/integrations/github-token`, { headers: { Accept: 'application/json', ...(await networkTokens.authHeaders()) }, signal: AbortSignal.timeout(10000) });
++ const body = await res.json().catch(() => ({}));
++ if (res.status === 401) networkTokens.invalidate();
++ const value = res.ok && typeof body.token === 'string' && body.token ? body.token : null;
++ cachedToken = { value, until: nowMs() + (res.ok || res.status === 404 ? TOKEN_TTL_MS : 60 * 1000), source: value ? 'network' : res.status === 404 ? 'none' : `network error ${res.status}` };
++ } catch (err) {
++ cachedToken = { value: null, until: nowMs() + 60 * 1000, source: `network unreachable: ${err.message}` };
++ }
++ return cachedToken.value;
++ }
++ const gh = async (path) => {
++ const token = await githubToken();
++ try {
++ return await getJson(`${GITHUB}${path}`, { headers: {
++ Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Blog changelog (+https://openvibe.blog)',
++ ...(token ? { Authorization: `Bearer ${token}` } : {}),
++ } });
++ } catch (err) {
++ // A revoked or replaced network token: ask again next time.
++ if (err.status === 401 && token && !c.githubToken) cachedToken.until = 0;
++ throw err;
++ }
++ };
/** [{ id, name, repo, origin, release, releasedAt }] for services that run a release and name a repository. */
async function registry() {
@@@ -349,7 +383,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
function sites() { return q.sites.all(); }
-- return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: () => ({ enabled: c.enabled, ...stats, pending: q.pending.all().length }) };
++ return { tick, collect, due, render, publish, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: q.pending.all().length }) };
}
/** A stable id for a batch (tests). */
ddiff --git a/test/changelog.test.js b/test/changelog.test.js
iindex c027ea2..afc8f5c 100644
---- a/test/changelog.test.js
++++ b/test/changelog.test.js
@@@ -129,6 +129,50 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.ok(one.next);
});
++ await check('the GitHub token: CHANGELOG_GITHUB_TOKEN, else the network\'s (Blog service token, kept ten minutes), else anonymous', async () => {
++ const seen = { auth: [], network: 0, oauth: [] };
++ let networkAnswer = { status: 200, body: { token: 'ghp_fromnetwork', source: 'database' } };
++ const fx = async (url, opts = {}) => {
++ const u = new URL(url);
++ const json = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
++ if (u.pathname === '/oauth/token') { seen.oauth.push(String(opts.body)); return json({ access_token: 'svc-tok', token_type: 'Bearer', expires_in: 300 }); }
++ if (u.pathname === '/internal/integrations/github-token') {
++ seen.network++;
++ assert.strictEqual(opts.headers.Authorization || opts.headers.authorization, 'Bearer svc-tok');
++ return json(networkAnswer.body, networkAnswer.status);
++ }
++ if (u.hostname === 'api.github.com') seen.auth.push((opts.headers && opts.headers.Authorization) || null);
++ return fetchImpl(url, opts);
++ };
++ const mk = (extra, oauth = { clientId: 'blog', clientSecret: 's3cret' }) => createChangelog({ config: { ...config, oauth, networkInternalUrl: 'http://network.internal', changelog: { ...config.changelog, ...extra } }, store: t.ctx.store, blogs: t.ctx.blogs, posts: t.ctx.posts, aiDrafts: null, fetchImpl: fx, log: { log() {}, warn() {} } });
++ const release = async (cl2, n) => { releases.live = `aaaaaaa1000${n}`; compares[releases.live] = { status: 'ahead', commits: [commit(900 + n, `Token check ${n}`)], files: [{ additions: 1, deletions: 0 }] }; await cl2.collect(); };
++
++ const net = mk({ githubToken: '' });
++ await release(net, 1);
++ assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
++ assert.ok(seen.oauth[0].includes('audience=openvibe.network') && seen.oauth[0].includes('network.integration.github.read'));
++ assert.strictEqual(net.stats().github_token, 'network');
++ await release(net, 2);
++ assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromnetwork');
++ assert.strictEqual(seen.network, 1, 'kept ten minutes');
++
++ networkAnswer = { status: 404, body: { error: 'not_configured' } };
++ t.clock.advance(11 * 60 * 1000);
++ await release(net, 3);
++ assert.strictEqual(seen.auth.pop(), null, 'none set: anonymous');
++ assert.strictEqual(net.stats().github_token, 'none');
++
++ const env = mk({ githubToken: 'ghp_fromenv' });
++ const before = seen.network;
++ await release(env, 4);
++ assert.strictEqual(seen.auth.pop(), 'Bearer ghp_fromenv');
++ assert.strictEqual(seen.network, before, 'the environment wins; Network is not asked');
++
++ const none = mk({ githubToken: '' }, { clientId: 'blog', clientSecret: '' });
++ await release(none, 5);
++ assert.strictEqual(seen.auth.pop(), null, 'no service credentials: anonymous');
++ });
++
await t.close();
done();
})();