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

OP OpenVibers committed 24 Sept 2026 · 8c9fa75753 · 11 files, +270 −10


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 25831c75..04e1a766 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex bf247a6a..7f40c1ae 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/admin.html b/public/admin.html
iindex 44f7242c..6bb4e41e 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -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');
ddiff --git a/server/db/database.js b/server/db/database.js
iindex e3593d62..f3565eb9 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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);
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 59b34433..ff662d89 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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', []],
ddiff --git a/server/index.js b/server/index.js
iindex cbe6513c..745b5147 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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({ issu
app.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.
ddiff --git a/server/integrations/github.js b/server/integrations/github.js
nnew file mode 100644
iindex 00000000..daef4371
---- /dev/null
++++ b/server/integrations/github.js
@@@ -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 };
ddiff --git a/server/registry/library-tags.js b/server/registry/library-tags.js
iindex dadd267f..48a3cd61 100644
---- a/server/registry/library-tags.js
++++ b/server/registry/library-tags.js
@@@ -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;
ddiff --git a/server/secrets.js b/server/secrets.js
iindex 086dc4ca..9bb90cf0 100644
---- a/server/secrets.js
++++ b/server/secrets.js
@@@ -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.
ddiff --git a/test/github-integration.test.js b/test/github-integration.test.js
nnew file mode 100644
iindex 00000000..163187b1
---- /dev/null
++++ b/test/github-integration.test.js
@@@ -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); });
ddiff --git a/test/provider-secrets.test.js b/test/provider-secrets.test.js
iindex f425e792..2478478e 100644
---- a/test/provider-secrets.test.js
++++ b/test/provider-secrets.test.js
@@@ -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(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/);