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
Changes
@@@ -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, fetchImif (!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). */@@@ -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();})();
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
* shipped" shows, with the latest patch notes post to link to.
* shipped" shows, with the latest patch notes post to link to.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
const GITHUB = 'https://api.github.com';
const GITHUB = 'https://api.github.com';
const clean = (s, n) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, n);
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
@@ -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 });
if (!res.ok) throw Object.assign(new Error(`${url.replace(/\?.*$/, '')} answered ${res.status}`), { status: res.status });
return res.json();
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. */
/** [{ id, name, repo, origin, release, releasedAt }] for services that run a release and name a repository. */
async function registry() {
async function registry() {
@@ -349,7 +383,7 @@ function createChangelog({ config, store, blogs, posts, aiDrafts = null, fetchIm
@@ -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. */
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
function sites() { return q.sites.all(); }
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). */
/** A stable id for a batch (tests). */
@@ -129,6 +129,50 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -129,6 +129,50 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.ok(one.next);
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();
await t.close();
done();
done();
})();
})();