openvibers/openvibe.network Commits
The GitHub token is configurable in admin (Settings → GitHub, owner-only): GITHUB_TOKEN in the environment first, else the saved value; admin shows its source and last four characters and a Test that asks GitHub for the rate limit, never the value. The registry's library tags read it at call time; OpenVibe.Blog reads it over GET /internal/integrations/github-token with capability network.integration.github.read (Contracts 0.37.0, granted to blog only, no shared key). test/github-integration.test.js
Changes
@@@ -20,7 +20,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -1465,9 +1465,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.36.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",-- "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==",++ "version": "0.37.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0",++ "integrity": "sha512-jNbMAiESjoBDx9ZYTDCavAV6aG+Ug3ymQcMBszR7Wd6086nL5ESdrT+JpHkpDX1P4LA8g0XAXSBxAnjHc/jM3g==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -23,7 +23,7 @@"helmet": "^8.1.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0","uuid": "^11.1.0",@@@ -281,12 +281,14 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa<button onclick="showSettingsSub('media',this)">Media Tools</button><button onclick="showSettingsSub('urls',this)">Network URLs</button><button onclick="showSettingsSub('discord',this)">Discord</button>++ <button class="owner-only" onclick="showSettingsSub('github',this)">GitHub</button></div><div id="settings-tools" class="settings-sub active"></div><div id="settings-streamer" class="settings-sub" style="display:none"></div><div id="settings-media" class="settings-sub" style="display:none"></div><div id="settings-urls" class="settings-sub" style="display:none"></div><div id="settings-discord" class="settings-sub" style="display:none"></div>++ <div id="settings-github" class="settings-sub" style="display:none"></div></div><!-- TTS -->@@@ -1361,11 +1363,13 @@ function showSettingsSub(sub, btn) {document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';++ document.getElementById('settings-github').style.display = sub === 'github' ? 'block' : 'none';if (sub === 'tools') loadToolsSettings();else if (sub === 'streamer') loadStreamerSettings();else if (sub === 'media') loadStreamerMediaTools();else if (sub === 'urls') loadUrlRegistry();else if (sub === 'discord') loadDiscordSettings();++ else if (sub === 'github') loadGithubSettings();}async function loadSettings() {@@@ -3667,6 +3671,53 @@ async function loadDiscordSettings() {}}++// ═══════════════════════════════════════════════════════════════++// GitHub integration (owner-only): the read-only token for the registry and Blog's changelog++// ═══════════════════════════════════════════════════════════════++async function loadGithubSettings() {++ const c = document.getElementById('settings-github');++ c.innerHTML = '<p class="muted">Loading…</p>';++ try {++ const g = (await api('/api/admin/integrations/github')).github || {};++ const where = g.source === 'env' ? `set in the environment (<code>${esc(g.env)}</code>)` : g.source === 'database' ? 'saved here' : 'not set';++ const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (${where})</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;++ c.innerHTML = `++ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">++ <h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}++ </div>++ <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}. Create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions. The token is never shown again after saving.</p>++ ${g.source === 'env' ? `<p class="muted">It comes from <code>${esc(g.env)}</code> in the server's environment file; change it there.</p>` : `++ <div class="setting-row"><label for="github-token-input">Token</label>++ <input type="password" id="github-token-input" autocomplete="off" placeholder="${g.set ? 'Replace the saved token' : 'github_pat_… or ghp_…'}" style="width:100%;max-width:520px"></div>`}++ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">++ ${g.source === 'env' ? '' : '<button type="button" class="btn btn-primary" onclick="saveGithubToken()"><i class="fa-solid fa-save"></i> Save token</button>'}++ <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>++ ${g.source === 'database' ? '<button type="button" class="btn btn-outline" onclick="clearGithubToken()"><i class="fa-solid fa-trash"></i> Remove</button>' : ''}++ </div>++ <div id="github-test-result" class="muted" style="margin-top:10px"></div>`;++ } catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }++}++async function saveGithubToken() {++ const el = document.getElementById('github-token-input');++ const token = (el && el.value || '').trim();++ if (!token) return toast('Paste a token first', 'error');++ try { await api('/api/admin/integrations/github', { method: 'PUT', body: { token } }); el.value = ''; toast('GitHub token saved'); await loadGithubSettings(); testGithubToken(); }++ catch (e) { toast(e.message, 'error'); }++}++async function clearGithubToken() {++ if (!confirm('Remove the saved GitHub token? GitHub calls go back to the shared anonymous limit.')) return;++ try { await api('/api/admin/integrations/github', { method: 'DELETE' }); toast('GitHub token removed'); loadGithubSettings(); }++ catch (e) { toast(e.message, 'error'); }++}++async function testGithubToken() {++ const out = document.getElementById('github-test-result');++ if (out) out.textContent = 'Asking GitHub…';++ try {++ const t = (await api('/api/admin/integrations/github/test', { method: 'POST' })).test || {};++ if (out) out.innerHTML = t.ok ? `${t.authenticated ? '✅ Authenticated' : '⚠️ Anonymous'}: ${esc(String(t.remaining))} of ${esc(String(t.limit))} requests left this hour${t.reset ? ` (resets ${esc(new Date(t.reset).toLocaleTimeString())})` : ''}.` : `❌ GitHub refused it: ${esc(t.message || t.status)}`;++ } catch (e) { if (out) out.textContent = e.message; }++}++async function saveDiscordSettings() {try {const form = document.getElementById('discord-settings-form');@@@ -633,6 +633,7 @@ function initDb(dbPath) {['discord_alert_message', '', 'string'],['discord_oauth_client_id', '', 'string'],['discord_oauth_client_secret', '', 'secret'],++ ['github_token', '', 'secret'], // server/integrations/github.js (admin → Settings → GitHub)];const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);@@@ -60,6 +60,8 @@ const DEFAULT_GRANTS = [['wiki', 'media.object.read', 'openvibe.media', ['wiki']],// Wave 16: Blog (same shape as Wiki; it may hide the thread of a post that stopped being public).['blog', 'identity.subject.resolve', SELF_AUDIENCE, []],++ // The network changelog reads the GitHub token the owner configures (server/integrations/github.js).++ ['blog', 'network.integration.github.read', SELF_AUDIENCE, []],['blog', 'events.event.publish', 'openvibe.events', []],['blog', 'community.comment.write', 'openvibe.community', []],['blog', 'community.comment.moderate', 'openvibe.community', []],@@@ -384,6 +384,9 @@ const jwt = require('jsonwebtoken');const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());++// The GitHub token (admin → Settings → GitHub, or GITHUB_TOKEN): owner-only admin, and Blog's changelog reads it.++app.use('/api/admin/integrations/github', requireAuth, require('./integrations/github').adminRouter(db));++app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));// ── Routes ───────────────────────────────────────────────────// Public key endpoint (services fetch this to verify JWTs).@@@ -398,7 +401,7 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issuapp.use(ecosystem.router());ecosystem.start();// The released libraries' latest published tags (not the versions Network installs) for the registry.--require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases }).start();++require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) }).start();// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.app.use(require('./status/routes').createStatusRoutes({ ecosystem }));// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * The network's GitHub API token (read-only, public repositories). GitHub allows 60 anonymous++ * requests an hour per address, shared by everything on the host; with a token it is 5,000.++ *++ * Where it comes from (server/secrets.js, key github_token): GITHUB_TOKEN in network.env first, else++ * the value the owner saves in admin (Settings → GitHub). The value is never shown or logged: admin++ * sees its source and last four characters, and a test that asks GitHub for the token's rate limit.++ *++ * Who reads it:++ * Network itself the registry's library tags (server/registry/library-tags.js)++ * OpenVibe.Blog the network changelog, over GET /internal/integrations/github-token with its++ * service token (capability network.integration.github.read, granted to blog only)++ */++const express = require('express');++const secrets = require('../secrets');++const { requireOwner } = require('../auth/owner-guard');++++const KEY = 'github_token';++// Classic (ghp_/gho_/ghs_/ghu_/ghr_), fine-grained (github_pat_) and legacy 40-hex tokens.++const TOKEN_RE = /^(gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{40,255}|[0-9a-f]{40})$/;++++function tokenOf(db) {++ const v = db.getSetting ? db.getSetting(KEY) : null;++ return v && String(v).trim() ? String(v).trim() : null;++}++++function status(db) {++ const t = tokenOf(db);++ return { source: secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,++ used_by: ['the registry library versions (Network)', 'the network changelog and Patch notes (OpenVibe.Blog)'] };++}++++async function test(token, fetchImpl = globalThis.fetch) {++ const res = await fetchImpl('https://api.github.com/rate_limit', {++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ signal: AbortSignal.timeout(10000),++ });++ const body = await res.json().catch(() => ({}));++ const core = (body.resources && body.resources.core) || body.rate || {};++ return { ok: res.ok, status: res.status, authenticated: Boolean(token) && res.ok && (core.limit || 0) > 60,++ limit: core.limit ?? null, remaining: core.remaining ?? null, reset: core.reset ? new Date(core.reset * 1000).toISOString() : null,++ message: res.ok ? null : String(body.message || `GitHub answered ${res.status}`).slice(0, 200) };++}++++/** Owner-only admin routes, mounted at /api/admin/integrations/github behind requireAuth. */++function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {++ const r = express.Router();++ r.use(requireOwner);++ r.get('/', (req, res) => res.json({ ok: true, github: status(db) }));++ r.put('/', express.json({ limit: '4kb' }), (req, res) => {++ if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });++ const token = String((req.body && req.body.token) || '').trim();++ if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });++ db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(KEY, token, 'secret');++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));++ res.json({ ok: true, github: status(db) });++ });++ r.delete('/', (req, res) => {++ if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });++ db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));++ res.json({ ok: true, github: status(db) });++ });++ r.post('/test', async (req, res) => {++ try { res.json({ ok: true, test: await test(tokenOf(db), fetchImpl) }); }++ catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }++ });++ return r;++}++++/** GET /internal/integrations/github-token for a service holding network.integration.github.read. */++function internalHandler(db) {++ return (req, res) => {++ const t = tokenOf(db);++ res.set('Cache-Control', 'no-store');++ if (!t) return res.status(404).json({ error: 'not_configured', detail: 'No GitHub token is set (admin → Settings → GitHub, or GITHUB_TOKEN)' });++ res.json({ token: t, source: secrets.source(db, KEY) });++ };++}++++module.exports = { adminRouter, internalHandler, tokenOf, status, test, TOKEN_RE, KEY };@@@ -20,14 +20,15 @@ function latestOf(names) {return best ? best.name : null;}--function createLibraryTags({ fetchImpl = globalThis.fetch, token = process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, onUpdate = null, log = console } = {}) {++function createLibraryTags({ fetchImpl = globalThis.fetch, token = () => process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, onUpdate = null, log = console } = {}) {++ const tokenNow = () => (typeof token === 'function' ? token() : token) || '';const latest = new Map(); // package -> 'vX.Y.Z'let timer = null;async function refresh() {for (const [pkg, repo] of Object.entries(REPOS)) {try {const res = await fetchImpl(`https://api.github.com/repos/OpenVibers/${repo}/tags?per_page=100`, {-- headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(token ? { Authorization: `Bearer ${token}` } : {}) },++ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(tokenNow() ? { Authorization: `Bearer ${tokenNow()}` } : {}) },signal: AbortSignal.timeout(10000),});if (!res.ok) continue;@@@ -20,6 +20,7 @@ const SECRETS = [{ key: 'discord_bot_token', env: 'DISCORD_BOT_TOKEN', use: 'Discord bot (go-live and system alerts)' },{ key: 'discord_oauth_client_secret', env: 'DISCORD_OAUTH_CLIENT_SECRET', use: 'Discord account linking (OAuth client secret)' },{ key: 'vapid_private_key', env: 'VAPID_PRIVATE_KEY', use: 'web push (VAPID private key; must stay the pair of vapid_public_key)' },++ { key: 'github_token', env: 'GITHUB_TOKEN', use: 'GitHub API, read-only public repositories (registry library versions; OpenVibe.Blog\'s network changelog)' },];// Read environment-first too, but not secret: never blanked, only listed.nnew file mode 100644@@@ -0,0 +1,119 @@++'use strict';++// The network's GitHub token (server/integrations/github.js): env first, else what the owner saves in admin;++// admin never sees the value; only the owner changes it; Blog (network.integration.github.read) reads it++// over /internal with its service token and no other service can; the registry's library tags use it.++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++const { initDb } = require('../server/db/database');++const github = require('../server/integrations/github');++const principals = require('../server/identity/principals');++++const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-github-'));++const log = console.log; console.log = () => {};++const db = initDb(path.join(dir, 'network.db'));++console.log = log;++delete process.env.GITHUB_TOKEN;++process.env.OWNER_USERNAME = 'goosely';++db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();++db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();++db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();++++const TOKEN = 'github_pat_' + 'A1b2C3d4E5'.repeat(6);++const seen = [];++const fakeGithub = async (url, opts) => {++ seen.push(opts.headers.Authorization || null);++ const authed = Boolean(opts.headers.Authorization);++ return { ok: true, status: 200, json: async () => ({ resources: { core: { limit: authed ? 5000 : 60, remaining: authed ? 4999 : 59, reset: 1790000000 } } }) };++};++++const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++const app = express();++app.use(express.urlencoded({ extended: true }));++app.locals.db = db;++app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++app.locals.privateKey = keys.privateKey;++app.locals.publicKey = keys.publicKey;++app.use('/oauth', require('../server/auth/oauth-routes'));++app.use((req, _res, next) => { const u = req.headers['x-user']; req.user = u ? db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });++app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));++app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));++const server = http.createServer(app);++++(async () => {++ await new Promise(r => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = (method, p, { user, body, headers } = {}) => fetch(base + p, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(user ? { 'x-user': user } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined })++ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), text: await r.text() })).then(r => ({ ...r, body: JSON.parse(r.text || '{}') }));++ const svcToken = (client, secret) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: secret, audience: 'openvibe.network' }) }).then(r => r.json());++ const A = '/api/admin/integrations/github';++ try {++ // Owner only.++ assert.strictEqual((await call('GET', A)).status, 403);++ assert.strictEqual((await call('GET', A, { user: 'anadmin' })).status, 403, 'admins do not manage secrets');++ let r = await call('GET', A, { user: 'goosely' });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.env], [false, 'unset', 'GITHUB_TOKEN']);++++ // Validation, save, never echoed.++ assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: 'hunter2' } })).status, 400);++ r = await call('PUT', A, { user: 'goosely', body: { token: TOKEN } });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');++ assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);++ assert.strictEqual(github.tokenOf(db), TOKEN);++ const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();++ assert.strictEqual(audit.length, 1);++ assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');++ assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));++++ // Test asks GitHub with the token.++ r = await call('POST', A + '/test', { user: 'goosely' });++ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit, r.body.test.remaining], [true, 5000, 4999]);++ assert.strictEqual(seen.pop(), `Bearer ${TOKEN}`);++++ // Blog reads it with its service token; another service and the legacy key cannot.++ const blog = await svcToken('blog', 'blog-secret');++ assert.ok(blog.access_token, JSON.stringify(blog));++ assert.ok(blog.scope.split(' ').includes('network.integration.github.read'));++ r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.body, { token: TOKEN, source: 'database' });++ assert.strictEqual(r.cache, 'no-store');++ const live = await svcToken('live', 'live-secret');++ assert.strictEqual((await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${live.access_token}` } })).status, 403, 'only blog holds the capability');++ assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token', { headers: { 'x-internal-key': 'legacy-key' } })).status), 'no legacy key');++ assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token')).status), 'no credential');++++ // Clear → 404 not_configured; the test falls back to anonymous.++ r = await call('DELETE', A, { user: 'goosely' });++ assert.deepStrictEqual([r.status, r.body.github.set], [200, false]);++ r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });++ assert.deepStrictEqual([r.status, r.body.error], [404, 'not_configured']);++ r = await call('POST', A + '/test', { user: 'goosely' });++ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit], [false, 60]);++ assert.strictEqual(seen.pop(), null);++++ // The environment wins and cannot be changed from admin.++ process.env.GITHUB_TOKEN = 'ghp_' + 'Z'.repeat(36);++ r = await call('GET', A, { user: 'goosely' });++ assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);++ assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);++ assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);++ assert.strictEqual(github.tokenOf(db), process.env.GITHUB_TOKEN);++ delete process.env.GITHUB_TOKEN;++++ // The registry's library tags read the token at call time.++ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');++ assert.ok(/token: \(\) => require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');++ assert.ok(src.indexOf("app.get('/internal/integrations/github-token'") < src.indexOf("app.use('/internal', require('./internal/routes'))"), 'mounted before the /internal router');++ } finally {++ server.close();++ fs.rmSync(dir, { recursive: true, force: true });++ }++ console.log('github integration: all checks passed');++})().catch((e) => { console.error(e); process.exit(1); });@@@ -46,7 +46,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lassert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');const rep = secrets.report(db);-- assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key']);++ assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');assert.match(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -1465,9 +1465,9 @@
@@ -1465,9 +1465,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.36.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
- "integrity": "sha512-dDl0IDlVngVksOJZAisLpUJ32RIZJHy6MfpxkNfi/m4AHi86TWLkGcj3b1BdiyL7DjtNm6aX6PfA3ff1O9Dbdg==",
+ "version": "0.37.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0",
+ "integrity": "sha512-jNbMAiESjoBDx9ZYTDCavAV6aG+Ug3ymQcMBszR7Wd6086nL5ESdrT+JpHkpDX1P4LA8g0XAXSBxAnjHc/jM3g==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
+ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.36.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.12.0",
"uuid": "^11.1.0",
"uuid": "^11.1.0",
@@ -281,12 +281,14 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -281,12 +281,14 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<button onclick="showSettingsSub('media',this)">Media Tools</button>
<button onclick="showSettingsSub('media',this)">Media Tools</button>
<button onclick="showSettingsSub('urls',this)">Network URLs</button>
<button onclick="showSettingsSub('urls',this)">Network URLs</button>
<button onclick="showSettingsSub('discord',this)">Discord</button>
<button onclick="showSettingsSub('discord',this)">Discord</button>
+ <button class="owner-only" onclick="showSettingsSub('github',this)">GitHub</button>
</div>
</div>
<div id="settings-tools" class="settings-sub active"></div>
<div id="settings-tools" class="settings-sub active"></div>
<div id="settings-streamer" class="settings-sub" style="display:none"></div>
<div id="settings-streamer" class="settings-sub" style="display:none"></div>
<div id="settings-media" class="settings-sub" style="display:none"></div>
<div id="settings-media" class="settings-sub" style="display:none"></div>
<div id="settings-urls" class="settings-sub" style="display:none"></div>
<div id="settings-urls" class="settings-sub" style="display:none"></div>
<div id="settings-discord" class="settings-sub" style="display:none"></div>
<div id="settings-discord" class="settings-sub" style="display:none"></div>
+ <div id="settings-github" class="settings-sub" style="display:none"></div>
</div>
</div>
<!-- TTS -->
<!-- TTS -->
@@ -1361,11 +1363,13 @@ function showSettingsSub(sub, btn) {
@@ -1361,11 +1363,13 @@ function showSettingsSub(sub, btn) {
document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';
document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';
document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';
document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';
document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';
document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';
+ document.getElementById('settings-github').style.display = sub === 'github' ? 'block' : 'none';
if (sub === 'tools') loadToolsSettings();
if (sub === 'tools') loadToolsSettings();
else if (sub === 'streamer') loadStreamerSettings();
else if (sub === 'streamer') loadStreamerSettings();
else if (sub === 'media') loadStreamerMediaTools();
else if (sub === 'media') loadStreamerMediaTools();
else if (sub === 'urls') loadUrlRegistry();
else if (sub === 'urls') loadUrlRegistry();
else if (sub === 'discord') loadDiscordSettings();
else if (sub === 'discord') loadDiscordSettings();
+ else if (sub === 'github') loadGithubSettings();
}
}
async function loadSettings() {
async function loadSettings() {
@@ -3667,6 +3671,53 @@ async function loadDiscordSettings() {
@@ -3667,6 +3671,53 @@ async function loadDiscordSettings() {
}
}
}
}
+// ═══════════════════════════════════════════════════════════════
+// GitHub integration (owner-only): the read-only token for the registry and Blog's changelog
+// ═══════════════════════════════════════════════════════════════
+async function loadGithubSettings() {
+ const c = document.getElementById('settings-github');
+ c.innerHTML = '<p class="muted">Loading…</p>';
+ try {
+ const g = (await api('/api/admin/integrations/github')).github || {};
+ const where = g.source === 'env' ? `set in the environment (<code>${esc(g.env)}</code>)` : g.source === 'database' ? 'saved here' : 'not set';
+ const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (${where})</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;
+ c.innerHTML = `
+ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">
+ <h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}
+ </div>
+ <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}. Create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions. The token is never shown again after saving.</p>
+ ${g.source === 'env' ? `<p class="muted">It comes from <code>${esc(g.env)}</code> in the server's environment file; change it there.</p>` : `
+ <div class="setting-row"><label for="github-token-input">Token</label>
+ <input type="password" id="github-token-input" autocomplete="off" placeholder="${g.set ? 'Replace the saved token' : 'github_pat_… or ghp_…'}" style="width:100%;max-width:520px"></div>`}
+ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
+ ${g.source === 'env' ? '' : '<button type="button" class="btn btn-primary" onclick="saveGithubToken()"><i class="fa-solid fa-save"></i> Save token</button>'}
+ <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>
+ ${g.source === 'database' ? '<button type="button" class="btn btn-outline" onclick="clearGithubToken()"><i class="fa-solid fa-trash"></i> Remove</button>' : ''}
+ </div>
+ <div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
+ } catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
+}
+async function saveGithubToken() {
+ const el = document.getElementById('github-token-input');
+ const token = (el && el.value || '').trim();
+ if (!token) return toast('Paste a token first', 'error');
+ try { await api('/api/admin/integrations/github', { method: 'PUT', body: { token } }); el.value = ''; toast('GitHub token saved'); await loadGithubSettings(); testGithubToken(); }
+ catch (e) { toast(e.message, 'error'); }
+}
+async function clearGithubToken() {
+ if (!confirm('Remove the saved GitHub token? GitHub calls go back to the shared anonymous limit.')) return;
+ try { await api('/api/admin/integrations/github', { method: 'DELETE' }); toast('GitHub token removed'); loadGithubSettings(); }
+ catch (e) { toast(e.message, 'error'); }
+}
+async function testGithubToken() {
+ const out = document.getElementById('github-test-result');
+ if (out) out.textContent = 'Asking GitHub…';
+ try {
+ const t = (await api('/api/admin/integrations/github/test', { method: 'POST' })).test || {};
+ if (out) out.innerHTML = t.ok ? `${t.authenticated ? '✅ Authenticated' : '⚠️ Anonymous'}: ${esc(String(t.remaining))} of ${esc(String(t.limit))} requests left this hour${t.reset ? ` (resets ${esc(new Date(t.reset).toLocaleTimeString())})` : ''}.` : `❌ GitHub refused it: ${esc(t.message || t.status)}`;
+ } catch (e) { if (out) out.textContent = e.message; }
+}
+
async function saveDiscordSettings() {
async function saveDiscordSettings() {
try {
try {
const form = document.getElementById('discord-settings-form');
const form = document.getElementById('discord-settings-form');
@@ -633,6 +633,7 @@ function initDb(dbPath) {
@@ -633,6 +633,7 @@ function initDb(dbPath) {
['discord_alert_message', '', 'string'],
['discord_alert_message', '', 'string'],
['discord_oauth_client_id', '', 'string'],
['discord_oauth_client_id', '', 'string'],
['discord_oauth_client_secret', '', 'secret'],
['discord_oauth_client_secret', '', 'secret'],
+ ['github_token', '', 'secret'], // server/integrations/github.js (admin → Settings → GitHub)
];
];
const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
const insertSeed = db.prepare('INSERT OR IGNORE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);
for (const [k, v, t] of alwaysSeed) insertSeed.run(k, v, t);
@@ -60,6 +60,8 @@ const DEFAULT_GRANTS = [
@@ -60,6 +60,8 @@ const DEFAULT_GRANTS = [
['wiki', 'media.object.read', 'openvibe.media', ['wiki']],
['wiki', 'media.object.read', 'openvibe.media', ['wiki']],
// Wave 16: Blog (same shape as Wiki; it may hide the thread of a post that stopped being public).
// Wave 16: Blog (same shape as Wiki; it may hide the thread of a post that stopped being public).
['blog', 'identity.subject.resolve', SELF_AUDIENCE, []],
['blog', 'identity.subject.resolve', SELF_AUDIENCE, []],
+ // The network changelog reads the GitHub token the owner configures (server/integrations/github.js).
+ ['blog', 'network.integration.github.read', SELF_AUDIENCE, []],
['blog', 'events.event.publish', 'openvibe.events', []],
['blog', 'events.event.publish', 'openvibe.events', []],
['blog', 'community.comment.write', 'openvibe.community', []],
['blog', 'community.comment.write', 'openvibe.community', []],
['blog', 'community.comment.moderate', 'openvibe.community', []],
['blog', 'community.comment.moderate', 'openvibe.community', []],
@@ -384,6 +384,9 @@ const jwt = require('jsonwebtoken');
@@ -384,6 +384,9 @@ const jwt = require('jsonwebtoken');
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
const requireAuth = require('./auth/session').makeRequireAuth(() => ({ db, publicKey, config }), authRoutes.signToken);
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
// The moderation audit log for staff (staff.moderation.logs): server/admin/moderation-audit.js.
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
app.use('/api/v1/staff/moderation-audit', requireAuth, moderationAudit.router());
+// The GitHub token (admin → Settings → GitHub, or GITHUB_TOKEN): owner-only admin, and Blog's changelog reads it.
+app.use('/api/admin/integrations/github', requireAuth, require('./integrations/github').adminRouter(db));
+app.get('/internal/integrations/github-token', require('./identity/principals').guard('network.integration.github.read', { legacy: false }), require('./integrations/github').internalHandler(db));
// ── Routes ───────────────────────────────────────────────────
// ── Routes ───────────────────────────────────────────────────
// Public key endpoint (services fetch this to verify JWTs).
// Public key endpoint (services fetch this to verify JWTs).
@@ -398,7 +401,7 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issu
@@ -398,7 +401,7 @@ const ecosystem = require('./registry/ecosystem').createEcosystemRegistry({ issu
app.use(ecosystem.router());
app.use(ecosystem.router());
ecosystem.start();
ecosystem.start();
// The released libraries' latest published tags (not the versions Network installs) for the registry.
// The released libraries' latest published tags (not the versions Network installs) for the registry.
-require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases }).start();
+require('./registry/library-tags').createLibraryTags({ onUpdate: require('./registry/exposure').setLibraryReleases, token: () => require('./integrations/github').tokenOf(db) }).start();
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
// Operator status: GET /status (server-rendered, noindex), /api/v1/status, /api/v1/status/slo.
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
// What shipped network-wide: the changelog proxy every site's widget reads, and /updates.
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * The network's GitHub API token (read-only, public repositories). GitHub allows 60 anonymous
+ * requests an hour per address, shared by everything on the host; with a token it is 5,000.
+ *
+ * Where it comes from (server/secrets.js, key github_token): GITHUB_TOKEN in network.env first, else
+ * the value the owner saves in admin (Settings → GitHub). The value is never shown or logged: admin
+ * sees its source and last four characters, and a test that asks GitHub for the token's rate limit.
+ *
+ * Who reads it:
+ * Network itself the registry's library tags (server/registry/library-tags.js)
+ * OpenVibe.Blog the network changelog, over GET /internal/integrations/github-token with its
+ * service token (capability network.integration.github.read, granted to blog only)
+ */
+const express = require('express');
+const secrets = require('../secrets');
+const { requireOwner } = require('../auth/owner-guard');
+
+const KEY = 'github_token';
+// Classic (ghp_/gho_/ghs_/ghu_/ghr_), fine-grained (github_pat_) and legacy 40-hex tokens.
+const TOKEN_RE = /^(gh[pousr]_[A-Za-z0-9]{30,255}|github_pat_[A-Za-z0-9_]{40,255}|[0-9a-f]{40})$/;
+
+function tokenOf(db) {
+ const v = db.getSetting ? db.getSetting(KEY) : null;
+ return v && String(v).trim() ? String(v).trim() : null;
+}
+
+function status(db) {
+ const t = tokenOf(db);
+ return { source: secrets.source(db, KEY), env: secrets.envName(KEY), set: Boolean(t), last4: t ? t.slice(-4) : null,
+ used_by: ['the registry library versions (Network)', 'the network changelog and Patch notes (OpenVibe.Blog)'] };
+}
+
+async function test(token, fetchImpl = globalThis.fetch) {
+ const res = await fetchImpl('https://api.github.com/rate_limit', {
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ signal: AbortSignal.timeout(10000),
+ });
+ const body = await res.json().catch(() => ({}));
+ const core = (body.resources && body.resources.core) || body.rate || {};
+ return { ok: res.ok, status: res.status, authenticated: Boolean(token) && res.ok && (core.limit || 0) > 60,
+ limit: core.limit ?? null, remaining: core.remaining ?? null, reset: core.reset ? new Date(core.reset * 1000).toISOString() : null,
+ message: res.ok ? null : String(body.message || `GitHub answered ${res.status}`).slice(0, 200) };
+}
+
+/** Owner-only admin routes, mounted at /api/admin/integrations/github behind requireAuth. */
+function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
+ const r = express.Router();
+ r.use(requireOwner);
+ r.get('/', (req, res) => res.json({ ok: true, github: status(db) }));
+ r.put('/', express.json({ limit: '4kb' }), (req, res) => {
+ if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
+ const token = String((req.body && req.body.token) || '').trim();
+ if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });
+ db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(KEY, token, 'secret');
+ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));
+ res.json({ ok: true, github: status(db) });
+ });
+ r.delete('/', (req, res) => {
+ if (secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
+ db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);
+ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));
+ res.json({ ok: true, github: status(db) });
+ });
+ r.post('/test', async (req, res) => {
+ try { res.json({ ok: true, test: await test(tokenOf(db), fetchImpl) }); }
+ catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }
+ });
+ return r;
+}
+
+/** GET /internal/integrations/github-token for a service holding network.integration.github.read. */
+function internalHandler(db) {
+ return (req, res) => {
+ const t = tokenOf(db);
+ res.set('Cache-Control', 'no-store');
+ if (!t) return res.status(404).json({ error: 'not_configured', detail: 'No GitHub token is set (admin → Settings → GitHub, or GITHUB_TOKEN)' });
+ res.json({ token: t, source: secrets.source(db, KEY) });
+ };
+}
+
+module.exports = { adminRouter, internalHandler, tokenOf, status, test, TOKEN_RE, KEY };
@@ -20,14 +20,15 @@ function latestOf(names) {
@@ -20,14 +20,15 @@ function latestOf(names) {
return best ? best.name : null;
return best ? best.name : null;
}
}
-function createLibraryTags({ fetchImpl = globalThis.fetch, token = process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, onUpdate = null, log = console } = {}) {
+function createLibraryTags({ fetchImpl = globalThis.fetch, token = () => process.env.GITHUB_TOKEN || '', intervalMs = 3600e3, onUpdate = null, log = console } = {}) {
+ const tokenNow = () => (typeof token === 'function' ? token() : token) || '';
const latest = new Map(); // package -> 'vX.Y.Z'
const latest = new Map(); // package -> 'vX.Y.Z'
let timer = null;
let timer = null;
async function refresh() {
async function refresh() {
for (const [pkg, repo] of Object.entries(REPOS)) {
for (const [pkg, repo] of Object.entries(REPOS)) {
try {
try {
const res = await fetchImpl(`https://api.github.com/repos/OpenVibers/${repo}/tags?per_page=100`, {
const res = await fetchImpl(`https://api.github.com/repos/OpenVibers/${repo}/tags?per_page=100`, {
- headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'OpenVibe.Network registry', ...(tokenNow() ? { Authorization: `Bearer ${tokenNow()}` } : {}) },
signal: AbortSignal.timeout(10000),
signal: AbortSignal.timeout(10000),
});
});
if (!res.ok) continue;
if (!res.ok) continue;
@@ -20,6 +20,7 @@ const SECRETS = [
@@ -20,6 +20,7 @@ const SECRETS = [
{ key: 'discord_bot_token', env: 'DISCORD_BOT_TOKEN', use: 'Discord bot (go-live and system alerts)' },
{ key: 'discord_bot_token', env: 'DISCORD_BOT_TOKEN', use: 'Discord bot (go-live and system alerts)' },
{ key: 'discord_oauth_client_secret', env: 'DISCORD_OAUTH_CLIENT_SECRET', use: 'Discord account linking (OAuth client secret)' },
{ key: 'discord_oauth_client_secret', env: 'DISCORD_OAUTH_CLIENT_SECRET', use: 'Discord account linking (OAuth client secret)' },
{ key: 'vapid_private_key', env: 'VAPID_PRIVATE_KEY', use: 'web push (VAPID private key; must stay the pair of vapid_public_key)' },
{ key: 'vapid_private_key', env: 'VAPID_PRIVATE_KEY', use: 'web push (VAPID private key; must stay the pair of vapid_public_key)' },
+ { key: 'github_token', env: 'GITHUB_TOKEN', use: 'GitHub API, read-only public repositories (registry library versions; OpenVibe.Blog\'s network changelog)' },
];
];
// Read environment-first too, but not secret: never blanked, only listed.
// Read environment-first too, but not secret: never blanked, only listed.
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+// The network's GitHub token (server/integrations/github.js): env first, else what the owner saves in admin;
+// admin never sees the value; only the owner changes it; Blog (network.integration.github.read) reads it
+// over /internal with its service token and no other service can; the registry's library tags use it.
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+const { initDb } = require('../server/db/database');
+const github = require('../server/integrations/github');
+const principals = require('../server/identity/principals');
+
+const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-github-'));
+const log = console.log; console.log = () => {};
+const db = initDb(path.join(dir, 'network.db'));
+console.log = log;
+delete process.env.GITHUB_TOKEN;
+process.env.OWNER_USERNAME = 'goosely';
+db.prepare("UPDATE oauth_clients SET client_secret = 'blog-secret' WHERE client_id = 'blog'").run();
+db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
+db.prepare("INSERT INTO users (id, username, password_hash) VALUES (1, 'goosely', 'x'), (2, 'anadmin', 'x')").run();
+
+const TOKEN = 'github_pat_' + 'A1b2C3d4E5'.repeat(6);
+const seen = [];
+const fakeGithub = async (url, opts) => {
+ seen.push(opts.headers.Authorization || null);
+ const authed = Boolean(opts.headers.Authorization);
+ return { ok: true, status: 200, json: async () => ({ resources: { core: { limit: authed ? 5000 : 60, remaining: authed ? 4999 : 59, reset: 1790000000 } } }) };
+};
+
+const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+const app = express();
+app.use(express.urlencoded({ extended: true }));
+app.locals.db = db;
+app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+app.locals.privateKey = keys.privateKey;
+app.locals.publicKey = keys.publicKey;
+app.use('/oauth', require('../server/auth/oauth-routes'));
+app.use((req, _res, next) => { const u = req.headers['x-user']; req.user = u ? db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });
+app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));
+app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));
+const server = http.createServer(app);
+
+(async () => {
+ await new Promise(r => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = (method, p, { user, body, headers } = {}) => fetch(base + p, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(user ? { 'x-user': user } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined })
+ .then(async r => ({ status: r.status, cache: r.headers.get('cache-control'), text: await r.text() })).then(r => ({ ...r, body: JSON.parse(r.text || '{}') }));
+ const svcToken = (client, secret) => fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: client, client_secret: secret, audience: 'openvibe.network' }) }).then(r => r.json());
+ const A = '/api/admin/integrations/github';
+ try {
+ // Owner only.
+ assert.strictEqual((await call('GET', A)).status, 403);
+ assert.strictEqual((await call('GET', A, { user: 'anadmin' })).status, 403, 'admins do not manage secrets');
+ let r = await call('GET', A, { user: 'goosely' });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.env], [false, 'unset', 'GITHUB_TOKEN']);
+
+ // Validation, save, never echoed.
+ assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: 'hunter2' } })).status, 400);
+ r = await call('PUT', A, { user: 'goosely', body: { token: TOKEN } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');
+ assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);
+ assert.strictEqual(github.tokenOf(db), TOKEN);
+ const audit = db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();
+ assert.strictEqual(audit.length, 1);
+ assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');
+ assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));
+
+ // Test asks GitHub with the token.
+ r = await call('POST', A + '/test', { user: 'goosely' });
+ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit, r.body.test.remaining], [true, 5000, 4999]);
+ assert.strictEqual(seen.pop(), `Bearer ${TOKEN}`);
+
+ // Blog reads it with its service token; another service and the legacy key cannot.
+ const blog = await svcToken('blog', 'blog-secret');
+ assert.ok(blog.access_token, JSON.stringify(blog));
+ assert.ok(blog.scope.split(' ').includes('network.integration.github.read'));
+ r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.body, { token: TOKEN, source: 'database' });
+ assert.strictEqual(r.cache, 'no-store');
+ const live = await svcToken('live', 'live-secret');
+ assert.strictEqual((await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${live.access_token}` } })).status, 403, 'only blog holds the capability');
+ assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token', { headers: { 'x-internal-key': 'legacy-key' } })).status), 'no legacy key');
+ assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token')).status), 'no credential');
+
+ // Clear → 404 not_configured; the test falls back to anonymous.
+ r = await call('DELETE', A, { user: 'goosely' });
+ assert.deepStrictEqual([r.status, r.body.github.set], [200, false]);
+ r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
+ assert.deepStrictEqual([r.status, r.body.error], [404, 'not_configured']);
+ r = await call('POST', A + '/test', { user: 'goosely' });
+ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit], [false, 60]);
+ assert.strictEqual(seen.pop(), null);
+
+ // The environment wins and cannot be changed from admin.
+ process.env.GITHUB_TOKEN = 'ghp_' + 'Z'.repeat(36);
+ r = await call('GET', A, { user: 'goosely' });
+ assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);
+ assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);
+ assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);
+ assert.strictEqual(github.tokenOf(db), process.env.GITHUB_TOKEN);
+ delete process.env.GITHUB_TOKEN;
+
+ // The registry's library tags read the token at call time.
+ const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
+ assert.ok(/token: \(\) => require\('\.\/integrations\/github'\)\.tokenOf\(db\)/.test(src), 'library tags get the token lazily');
+ assert.ok(src.indexOf("app.get('/internal/integrations/github-token'") < src.indexOf("app.use('/internal', require('./internal/routes'))"), 'mounted before the /internal router');
+ } finally {
+ server.close();
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ console.log('github integration: all checks passed');
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -46,7 +46,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -46,7 +46,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
assert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');
assert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');
assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
const rep = secrets.report(db);
const rep = secrets.report(db);
- assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key']);
+ assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);
assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');
assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');
assert.match(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
assert.match(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);