openvibers/openvibe.exchange Commits

Exchange: the Code page, its public API and the deep dive's repository source (#232)

OP OpenVibers committed 1 days ago · 22748ef103 · 16 files, +691 −7

Changes

ddiff --git a/.env.example b/.env.example
iindex 3b5f968..d3d8776 100644
---- a/.env.example
++++ b/.env.example
@@@ -35,6 +35,15 @@ OV_OAUTH_CLIENT_SECRET=
#OV_AI_URL=https://ai.openvibe.services
#OV_AI_AUDIENCE=openvibe.ai
#OV_AI_SCOPE=ai.run.create
++# OpenVibe.Sources: the github source's repository rankings and its live GitHub search (category code).
++# Exchange's own client-credentials grant (sources.item.read) reads it over loopback; nothing here calls GitHub.
++#OV_SOURCES_URL=http://127.0.0.1:4720
++#OV_SOURCES_AUDIENCE=openvibe.sources
++#OV_SOURCES_SCOPE=sources.item.read
++#EXCHANGE_CODE_REPOS_CACHE_MS=60000
++#EXCHANGE_CODE_SEARCH_CACHE_MS=600000
++#EXCHANGE_CODE_SEARCH_PER_MIN=20
++
# The owner console uses this Exchange service grant to read/write AI's admin JSON API.
# Network grants these capabilities to the existing `exchange` client; keep this scope to the needed AI capabilities.
#OV_AI_CONSOLE_SCOPE=ai.usage.read ai.workflow.manage ai.provider.manage
ddiff --git a/README.md b/README.md
iindex 4f75724..7de9b22 100644
---- a/README.md
++++ b/README.md
@@@ -30,6 +30,7 @@ for a person — and keeps only two things of its own: a person's chats and thei
| `/uncensored` | The 18+ gate on the account, the abliterated models and the hard floor. |
| `/ai` | Which OpenVibe products run which models through the same router, and what each costs a day, with the product's own mark. |
| `/status` | Uptime per offer, the model changelog and incidents. |
++| `/code` | GitHub repositories through OpenVibe.Sources: a deep repository search, Trending today / This week / Hot / New / Top, twelve language chips and one card per repository (stars, the week's gain, forks, language, topics, updated). `/api/v1/code/repos` and `/api/v1/code/search` are its public JSON. |
The old chat rows remain for a person's one-time import into Chat. `GET /internal/exchange/chat/export` requires Chat's service token with `exchange.chat.export` and the authenticated person's `x-ov-subject`; it returns only that person's unsealed chats.
@@@ -48,6 +49,9 @@ signed-in person is `private, no-store`; a public page is `public, max-age=60`.
turn or Try it, on the person's allowance, with this service's token (`ai.run.create`, namespace `ai.complete`).
- `GET .../api/v1/community/offers` — the live community offers; a pool says how many machines serve it (never
which ones), so the offer card can say "Community pool (N machines)" without naming a machine.
++- `GET http://127.0.0.1:4720/api/v1/code/repos` and `/api/v1/code/search` — the GitHub repositories
++ OpenVibe.Sources keeps (the `github` source, category `code`) and its live GitHub search, with this
++ service's `sources.item.read` grant. Exchange never calls GitHub itself.
- `GET https://openvibe.network/api/v1/machines` and `/api/v1/machine-pools` — the person's own machines, their
pools and the machines they can pool, with the person's Network token: names, free VRAM and RAM, the local
network key, and each member's approval and readiness. `POST /api/v1/machine-pools` proposes a pool and
ddiff --git a/public/css/exchange.css b/public/css/exchange.css
iindex 130fc1c..66f6097 100644
---- a/public/css/exchange.css
++++ b/public/css/exchange.css
@@@ -750,3 +750,77 @@ label.dd-depth input:focus-visible { outline: 2px solid var(--accent); outline-o
.dd-go-note { align-items: flex-start; }
.dd-go-note svg { margin-top: 3px; }
.dd > .dd-form + *, .dd > .dd-error + * { margin-top: 26px; }
++
++/* ── Code: GitHub repositories ─────────────────────────── */
++.cd { --cd-radius: 16px; }
++.cd-find { margin: 8px 0 14px; }
++.cd-find .ovk-find { max-width: 680px; }
++.cd-hint { margin: 8px 0 0; font-size: .82rem; line-height: 1.5; color: var(--ovk-muted); overflow-wrap: anywhere; }
++.cd-hint code { font-size: .92em; }
++.cd-tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 10px; }
++.cd-tab {
++ display: inline-flex; align-items: center; gap: 6px; min-height: 38px; padding: 0 13px;
++ border: 1px solid var(--ovk-line); border-radius: 999px; background: var(--ovk-panel);
++ color: var(--ovk-text); font-size: .86rem; font-weight: 600; text-decoration: none; white-space: nowrap;
++}
++.cd-tab svg { width: 15px; height: 15px; }
++.cd-tab:hover { border-color: var(--ovk-accent); }
++.cd-tab.is-on { border-color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 12%, var(--ovk-panel)); color: var(--ovk-accent); }
++.cd-langs { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 16px; }
++.cd-chip {
++ display: inline-flex; align-items: center; min-height: 30px; padding: 0 10px;
++ border: 1px solid var(--ovk-line); border-radius: 999px; background: transparent;
++ color: var(--ovk-muted); font-size: .78rem; text-decoration: none; white-space: nowrap;
++}
++.cd-chip:hover { border-color: var(--ovk-accent); color: var(--ovk-text); }
++.cd-chip.is-on { border-color: var(--ovk-accent); color: var(--ovk-accent); background: color-mix(in srgb, var(--ovk-accent) 10%, transparent); }
++.cd-results-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 14px; margin: 0 0 12px; }
++.cd-results-head h2 { margin: 0; font-size: 1.1rem; }
++.cd-count { color: var(--ovk-muted); font-size: .82rem; font-variant-numeric: tabular-nums; }
++.cd-clear { margin-left: auto; color: var(--ovk-accent); font-size: .84rem; }
++.cd-grid {
++ list-style: none; margin: 0; padding: 0;
++ display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px;
++}
++@media (min-width: 720px) { .cd-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
++@media (min-width: 1100px) { .cd-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
++.cd-card {
++ min-width: 0; display: flex; flex-direction: column; gap: 10px;
++ padding: 14px; border: 1px solid var(--ovk-line); border-radius: var(--cd-radius); background: var(--ovk-panel);
++}
++.cd-card:hover { border-color: color-mix(in srgb, var(--ovk-accent) 55%, var(--ovk-line)); }
++.cd-card-head { display: flex; align-items: center; gap: 10px; min-width: 0; }
++.cd-avatar { flex: none; width: 40px; height: 40px; border-radius: 12px; object-fit: cover; background: var(--ovk-panel-2); }
++.cd-avatar--letter { display: inline-grid; place-items: center; color: var(--ovk-accent); font-weight: 800; font-size: 1.05rem; }
++.cd-card-id { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
++.cd-name {
++ display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
++ color: var(--ovk-text); font-weight: 700; font-size: .95rem; text-decoration: none;
++}
++.cd-name:hover { color: var(--ovk-accent); text-decoration: underline; }
++.cd-owner { color: var(--ovk-muted); font-size: .76rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.cd-desc {
++ margin: 0; min-height: 2.7em; color: var(--ovk-text); font-size: .86rem; line-height: 1.45;
++ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; overflow-wrap: anywhere;
++}
++.cd-muted { color: var(--ovk-muted); }
++.cd-stats { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 0; font-size: .8rem; color: var(--ovk-muted); }
++.cd-stat { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
++.cd-stat svg { width: 13px; height: 13px; flex: none; }
++.cd-stat b { color: var(--ovk-text); font-variant-numeric: tabular-nums; }
++.cd-gain { color: var(--ovk-ok); font-weight: 600; white-space: nowrap; }
++.cd-lang { display: inline-flex; align-items: center; gap: 5px; color: var(--ovk-text); min-width: 0; }
++.cd-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--cd-lang, var(--ovk-accent)); flex: none; }
++.cd-topics { list-style: none; display: flex; flex-wrap: wrap; gap: 5px; margin: 0; padding: 0; }
++.cd-topics li {
++ max-width: 100%; padding: 2px 8px; border-radius: 999px; background: var(--ovk-panel-2);
++ color: var(--ovk-muted); font-size: .7rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
++}
++.cd-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: auto; font-size: .75rem; color: var(--ovk-muted); }
++.cd-open { margin-left: auto; color: var(--ovk-accent); font-weight: 600; text-decoration: none; white-space: nowrap; }
++.cd-open:hover { text-decoration: underline; }
++@media (max-width: 420px) {
++ .cd-card { padding: 12px; }
++ .cd-tab { min-height: 36px; padding: 0 11px; font-size: .82rem; }
++ .cd-chip { min-height: 28px; font-size: .75rem; }
++}
ddiff --git a/server/app.js b/server/app.js
iindex 010f479..a4815c1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -33,6 +33,8 @@ const { createNetwork } = require('./network');
const { createBench } = require('./bench');
const { createCompare } = require('./compare');
const { createOpenApi } = require('./openapi');
++const { createCode } = require('./code');
++const { createCodeRoutes } = require('./http/code');
const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
@@@ -123,7 +125,12 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Exchange cannot answer account events'); });
const openapi = createOpenApi({ config, fetchImpl: opts.openapiFetch || globalThis.fetch, log });
-- const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, log };
++ // GitHub repositories through Sources. A test or a preview may hand its own client; nothing else reads
++ // GitHub directly.
++ const code = opts.code || createCode({
++ config, fetchImpl: opts.sourcesFetchImpl || globalThis.fetch, tokenFetch: fetchImpl, now: opts.now || (() => Date.now()), log,
++ });
++ const ctx = { config, s, keys, sso, principal, ai, actor, network, bench, compare, analysis, arenaStore, history, openapi, chats, adult, adultAccount, characters, companionChats, groups, voice, sharing, accountData, accountSend, memory, notes, lorebooks, locker, noteTaker, indexnow, code, log };
const app = express();
app.disable('x-powered-by');
@@@ -147,7 +154,7 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media', 'https://avatars.githubusercontent.com'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://cloudflareinsights.com', 'https://openvibe.events', 'https://ai.openvibe.services'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
@@@ -331,6 +338,7 @@ async function createApp(opts = {}) {
app.use(createCompareSite(ctx));
// ── Exchange v1's JSON API: gone ────────────────────────
++ app.use(createCodeRoutes(ctx));
app.use('/api/v1', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
return contracts.http.sendProblem(res, 410, 'exchange.retired', {
ddiff --git a/server/code.js b/server/code.js
nnew file mode 100644
iindex 0000000..4212c6c
---- /dev/null
++++ b/server/code.js
@@@ -0,0 +1,114 @@
++'use strict';
++/**
++ * GitHub repositories, read from OpenVibe.Sources (server/code.js's sibling there): the `github` source in
++ * category `code` keeps the rankings and star history; Sources fetches GitHub with its own token, and
++ * Exchange never calls GitHub itself. This client uses Exchange's client-credentials grant
++ * (`sources.item.read`) over loopback and caches both reads: the rankings 60 seconds, a live search ten
++ * minutes (GitHub's search limit is the scarce resource).
++ *
++ * A list is what Sources last fetched; a search is a GitHub answer Sources just made. When Sources
++ * cannot answer the pages say so: no repository is invented and no star count is guessed.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++class CodeUnavailable extends Error {
++ constructor(detail = 'OpenVibe.Sources is not answering right now') {
++ super(detail);
++ this.code = 'code.unavailable';
++ this.status = 503;
++ }
++}
++
++function createCode({ config, fetchImpl = globalThis.fetch, tokenFetch = globalThis.fetch, now = () => Date.now(), log = console }) {
++ const tokens = config.oauth.clientSecret ? createServiceTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: config.sources.audience, scope: config.sources.scope, fetch: tokenFetch,
++ }) : null;
++ const lists = new Map();
++ const searches = new Map();
++ const bursts = new Map(); // actor → [request times in the current minute]
++ let warnedAt = 0;
++
++ const warned = (what, err) => {
++ if (now() - warnedAt < 60_000) return;
++ warnedAt = now();
++ log.warn(`[exchange] Sources ${what} unavailable: ${String((err && err.message) || err).slice(0, 200)}`);
++ };
++
++ async function get(path) {
++ if (!tokens) throw new CodeUnavailable('Exchange is not configured for OpenVibe.Sources');
++ const token = await tokens.getToken();
++ let res;
++ try {
++ res = await fetchImpl(`${config.sources.url}${path}`, {
++ headers: { accept: 'application/json', authorization: `Bearer ${token}` },
++ signal: AbortSignal.timeout(config.sources.timeoutMs),
++ });
++ } catch (err) {
++ throw new CodeUnavailable('OpenVibe.Sources is unreachable right now');
++ }
++ const text = await res.text().catch(() => '');
++ let body = null;
++ try { body = text ? JSON.parse(text) : null; } catch { body = null; }
++ if (!res.ok || !body) throw new CodeUnavailable(`OpenVibe.Sources answered ${res.status}`);
++ return body;
++ }
++
++ /** The cached rankings. */
++ async function repos({ list = 'trending', lang = null, topic = null, limit = 30 } = {}) {
++ const key = JSON.stringify([list, lang || '', topic || '', limit]);
++ const hit = lists.get(key);
++ if (hit && now() - hit.at < config.sources.reposCacheMs) return { ...hit.value, cached: true };
++ try {
++ const value = await get(`/api/v1/code/repos?list=${encodeURIComponent(list)}&limit=${encodeURIComponent(limit)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${topic ? `&topic=${encodeURIComponent(topic)}` : ''}`);
++ lists.set(key, { at: now(), value });
++ if (lists.size > 100) lists.delete(lists.keys().next().value);
++ return { ...value, cached: false };
++ } catch (err) {
++ warned('code list', err);
++ // A last copy for a minute is better than an empty page, marked stale so the page says the numbers
++ // are the last read rather than live.
++ if (hit && now() - hit.at < 5 * 60_000) return { ...hit.value, cached: true, stale: true };
++ throw err;
++ }
++ }
++
++ /** A live repository search, cached ten minutes. */
++ async function search({ q, lang = null, sort = 'best', page = 1 } = {}) {
++ const key = JSON.stringify([q, lang || '', sort, page]);
++ const hit = searches.get(key);
++ if (hit && now() - hit.at < config.sources.searchCacheMs) return { ...hit.value, cached: true };
++ try {
++ const value = await get(`/api/v1/code/search?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&page=${encodeURIComponent(page)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}`);
++ searches.set(key, { at: now(), value });
++ if (searches.size > 200) searches.delete(searches.keys().next().value);
++ return { ...value, cached: false };
++ } catch (err) {
++ warned('code search', err);
++ if (hit && now() - hit.at < 60 * 60_000) return { ...hit.value, cached: true, stale: true };
++ throw err;
++ }
++ }
++
++ /** Is this actor under the search limit? A person is one actor; a signed-out visitor is their address. */
++ function allow(actor) {
++ const id = String(actor || 'anonymous');
++ const at = now();
++ const recent = (bursts.get(id) || []).filter((t) => at - t < 60_000);
++ if (recent.length >= config.sources.searchPerMinute) {
++ bursts.set(id, recent);
++ return { allowed: false, retryAfter: Math.max(1, Math.ceil((recent[0] + 60_000 - at) / 1000)) };
++ }
++ recent.push(at);
++ bursts.set(id, recent);
++ if (bursts.size > 5000) {
++ for (const [k, v] of bursts) if (!v.length || at - v[v.length - 1] > 60_000) bursts.delete(k);
++ }
++ return { allowed: true };
++ }
++
++ return { repos, search, allow, configured: Boolean(tokens) };
++}
++
++module.exports = { createCode, CodeUnavailable };
ddiff --git a/server/config.js b/server/config.js
iindex 003621b..ebfae34 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -76,6 +76,19 @@ function load(env = process.env) {
waitMs: Math.max(1000, int(env.EXCHANGE_AI_WAIT_MS, 25_000)),
},
++ // OpenVibe.Sources (server/code.js): the GitHub repositories it keeps (source `github`, category
++ // `code`) for /code and the public code API. The token is Exchange's own client-credentials grant
++ // (sources.item.read); the read is over loopback and the public API caches it.
++ sources: {
++ url: trim(env.OV_SOURCES_URL || 'http://127.0.0.1:4720'),
++ audience: env.OV_SOURCES_AUDIENCE || 'openvibe.sources',
++ scope: env.OV_SOURCES_SCOPE || 'sources.item.read',
++ timeoutMs: Math.max(2000, int(env.EXCHANGE_SOURCES_TIMEOUT_MS, 10_000)),
++ reposCacheMs: Math.max(10_000, int(env.EXCHANGE_CODE_REPOS_CACHE_MS, 60_000)),
++ searchCacheMs: Math.max(60_000, int(env.EXCHANGE_CODE_SEARCH_CACHE_MS, 600_000)),
++ searchPerMinute: Math.max(1, int(env.EXCHANGE_CODE_SEARCH_PER_MIN, 20)),
++ },
++
// OpenVibe.Actor (server/actor.js): a companion or group turn is an Actor task of the signed-in
// person — their own Bearer on the wire, their allowance. The wait bounds one page POST; a turn still
// running then is reconciled on the next view.
ddiff --git a/server/http/code.js b/server/http/code.js
nnew file mode 100644
iindex 0000000..8aa7ff0
---- /dev/null
++++ b/server/http/code.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * /code and the public code API.
++ *
++ * GET /code?q=&list=trending|week|hot|new|top&lang=
++ * The page: a deep search box, the rankings, twelve language chips and one card per repository.
++ * GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=
++ * GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=
++ * Public, signed-out, cached. The search is cached ten minutes and rate-limited per actor (a person by
++ * subject, a visitor by address); the rankings 60 seconds. Sources is the only upstream.
++ */
++const express = require('express');
++const cache = require('openvibe-shared/cache-policy');
++const { http } = require('openvibe-contracts');
++const views = require('../views/code');
++const { raw } = require('../render/html');
++const { send } = require('../render/layout');
++
++const LISTS = new Set(['trending', 'week', 'month', 'hot', 'new', 'top']);
++const TAB_LISTS = new Set(['trending', 'week', 'hot', 'new', 'top']);
++const SORTS = new Set(['stars', 'updated', 'best']);
++const PUBLIC_HTML = cache.htmlHeaders({ maxAge: 60 });
++const text = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
++
++function createCodeRoutes({ config, code, log = console }) {
++ const r = express.Router();
++
++ r.get('/code', async (req, res) => {
++ const q = text(req.query.q, 200);
++ const askedList = text(req.query.list, 20) || 'trending';
++ const list = TAB_LISTS.has(askedList) ? askedList : 'trending';
++ const lang = text(req.query.lang, 60);
++ const now = Date.now();
++ let repos = [];
++ let total = null;
++ let stale = false;
++ let unavailable = false;
++ let notice = '';
++ try {
++ if (q) {
++ const gate = code.allow(req.viewer && req.viewer.subject ? req.viewer.subject : req.ip);
++ if (!gate.allowed) {
++ notice = `Too many searches from this account this minute. Try again in ${gate.retryAfter} seconds.`;
++ } else {
++ const out = await code.search({ q, lang, sort: 'best', page: 1 });
++ repos = out.items || [];
++ total = out.total == null ? null : Number(out.total);
++ stale = Boolean(out.stale);
++ }
++ } else {
++ const out = await code.repos({ list, lang, topic: null, limit: 24 });
++ repos = out.items || [];
++ stale = Boolean(out.stale);
++ }
++ } catch (err) {
++ unavailable = true;
++ log.warn(`[exchange] /code: ${String((err && err.message) || err).slice(0, 160)}`);
++ }
++ return send(res, 200, {
++ viewer: req.viewer, config, path: req.originalUrl,
++ title: q ? `Code: ${q}` : 'Code',
++ description: 'GitHub repositories trending today, this week and hot by stars gained, with search and a language filter. Read through OpenVibe.Sources.',
++ index: true, cache: PUBLIC_HTML, agentLayer: true,
++ body: raw(views.codePage({ list, lang, q: q || '', repos, total, now, stale, unavailable, search: Boolean(q), notice })),
++ });
++ });
++
++ r.get('/api/v1/code/repos', async (req, res) => {
++ const list = text(req.query.list, 20) || 'trending';
++ if (!LISTS.has(list)) return http.sendProblem(res, 422, 'code.bad_query', { detail: `list must be one of ${[...LISTS].join('|')}`, ctx: req.ov });
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);
++ try {
++ const out = await code.repos({ list, lang: text(req.query.lang, 60), topic: text(req.query.topic, 50), limit });
++ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=60');
++ res.json(out);
++ } catch (err) {
++ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'The repository list is not available right now', ctx: req.ov });
++ }
++ });
++
++ r.get('/api/v1/code/search', async (req, res) => {
++ const q = text(req.query.q, 300);
++ if (!q) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'q is required', ctx: req.ov });
++ const sort = text(req.query.sort, 10) || 'best';
++ if (!SORTS.has(sort)) return http.sendProblem(res, 422, 'code.bad_query', { detail: 'sort must be stars, updated or best', ctx: req.ov });
++ const page = Math.min(Math.max(parseInt(req.query.page, 10) || 1, 1), 10);
++ const actor = req.viewer && req.viewer.subject ? req.viewer.subject : req.ip;
++ const gate = code.allow(actor);
++ if (!gate.allowed) {
++ res.set('Retry-After', String(gate.retryAfter));
++ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'Too many repository searches this minute', ctx: req.ov });
++ }
++ try {
++ const out = await code.search({ q, lang: text(req.query.lang, 60), sort, page });
++ res.set('Cache-Control', out.stale ? 'public, max-age=30' : 'public, max-age=600');
++ res.json(out);
++ } catch (err) {
++ if (err && err.code === 'code.rate_limited') {
++ res.set('Retry-After', String(err.retryAfter || 60));
++ return http.sendProblem(res, 429, 'code.rate_limited', { detail: 'GitHub asked us to wait before another search', ctx: req.ov });
++ }
++ return http.sendProblem(res, 503, 'code.unavailable', { detail: 'GitHub search is not available right now', ctx: req.ov });
++ }
++ });
++
++ return r;
++}
++
++module.exports = { createCodeRoutes, LISTS, SORTS };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 337471b..2700d80 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -27,6 +27,7 @@ const PUBLIC_PAGES = [
{ path: '/own', changefreq: 'weekly', priority: 0.6, title: 'Your own endpoint', text: 'A private GPU for one model or your own Hugging Face weights; only you reach it, it scales to zero when idle, and your Balance pays for the seconds it runs.' },
{ path: '/uncensored', changefreq: 'weekly', priority: 0.6, title: 'Uncensored', text: 'Abliterated models for adults, with the floor: no minors, no non-consensual content, no mass harm.' },
{ path: '/rankings', changefreq: 'daily', priority: 0.8, title: 'Rankings', text: 'Model rankings by task category from blind Arena votes, beside each model’s price per million, measured speed and uptime.' },
++ { path: '/code', changefreq: 'daily', priority: 0.8, title: 'Code', text: 'GitHub repositories trending today, new and hot by stars gained, plus a deep repository search: read through OpenVibe.Sources.' },
{ path: '/ai', changefreq: 'daily', priority: 0.6, title: 'AI in OpenVibe', text: 'Which OpenVibe products run which models through the same router, and what each costs a day.' },
{ path: '/status', changefreq: 'hourly', priority: 0.5, title: 'Status', text: 'Uptime per offer, the model changelog, and incidents.' },
];
@@@ -106,7 +107,7 @@ function createDiscoveryRoutes(ctx) {
res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
name: SITE_NAME,
summary: DESCRIPTION,
-- details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account.',
++ details: 'Prices are read live from OpenVibe.AI and are the offer\'s own, per million tokens. The API is OpenAI-compatible at https://api.openvibe.exchange/v1/chat/completions and Anthropic-compatible at /v1/messages; model ids are deepseek/deepseek-chat and their families, or openvibe/auto to let the router pick by floor, ceiling and policy. A small free daily allowance comes with an account. GitHub repositories are read through OpenVibe.Sources and served at /api/v1/code/repos and /api/v1/code/search; nothing is invented when the source is behind.',
markdown: true,
base: site,
sections: [
@@@ -114,8 +115,10 @@ function createDiscoveryRoutes(ctx) {
{ title: 'OpenVibe.Exchange', url: abs('/'), note: 'every model, one account' },
{ title: 'Models', url: abs('/models'), note: 'prices and measured latency' },
{ title: 'Rankings', url: abs('/rankings'), note: 'blind Arena votes by task, beside price and speed' },
++ { title: 'Code', url: abs('/code'), note: 'GitHub trending, hot and new repositories, and a deep search' },
{ title: 'The API', url: abs('/api') },
{ title: 'Compute prices', url: abs('/compute'), note: 'GPUs and computers per hour; JSON at /api/compute/prices' },
++ { title: 'Code repositories API', url: abs('/api/v1/code/repos?list=week'), note: 'cached GitHub rankings from Sources; search at /api/v1/code/search?q=stars:>500' },
{ title: 'Start building', url: abs('/start') },
{ title: 'Why this model', url: abs('/why') },
{ title: 'Questions', url: abs('/faq') },
ddiff --git a/server/openapi.js b/server/openapi.js
iindex d68fa60..824239a 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -26,9 +26,39 @@ function analysisPaths(config) {
'/api/v1/analysis/compare': make('exchange.analysis.compare', ['models', 'measures', 'kind', 'day', 'rating_kind'].map(query)),
'/api/v1/analysis/models/{model}': make('exchange.analysis.model', [param('model'), query('days'), query('rating_kind')]),
'/api/v1/analysis/ratings/{kind}': make('exchange.analysis.ratings', [param('kind')]),
++ '/api/v1/code/repos': codeReposPath(query),
++ '/api/v1/code/search': codeSearchPath(query),
};
}
++
++function codeReposPath(query) {
++ return { get: {
++ operationId: 'exchange.code.repos', summary: 'GitHub repositories trending, new, hot by stars gained, or most starred',
++ security: [], servers: [{ url: 'https://openvibe.exchange' }],
++ parameters: ['list', 'lang', 'topic', 'limit'].map(query),
++ responses: {
++ '200': { description: 'Cached repository rankings with stars, 1-day and 7-day gains, forks, language, topics and dates' },
++ '422': { description: 'Invalid list or limit' },
++ '503': { description: 'OpenVibe.Sources is unavailable' },
++ },
++ } };
++}
++
++function codeSearchPath(query) {
++ return { get: {
++ operationId: 'exchange.code.search', summary: 'Search GitHub repositories with GitHub query syntax',
++ security: [], servers: [{ url: 'https://openvibe.exchange' }],
++ parameters: ['q', 'lang', 'sort', 'page'].map(query),
++ responses: {
++ '200': { description: 'Repository results in the same shape as /api/v1/code/repos, with total matches' },
++ '422': { description: 'q, sort or page is invalid' },
++ '429': { description: 'Too many searches this minute; Retry-After says when' },
++ '503': { description: 'GitHub search through OpenVibe.Sources is unavailable' },
++ },
++ } };
++}
++
function createOpenApi({ config, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
const ttl = config.api.openapiCacheMs;
let at = 0;
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ed5d695..e3d0117 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -28,6 +28,7 @@ const NAV = [
{ label: 'Docs', href: '/docs', icon: 'fa-code' },
{ label: 'Status', href: '/status', icon: 'fa-gauge' },
{ label: 'Rankings', href: '/rankings', icon: 'fa-chart-line', more: true },
++ { label: 'Code', href: '/code', icon: 'fa-code', more: true },
];
const hashes = new Map();
ddiff --git a/server/views/code.js b/server/views/code.js
nnew file mode 100644
iindex 0000000..d7b4963
---- /dev/null
++++ b/server/views/code.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * /code: GitHub repositories through OpenVibe.Sources. A deep search box, the rankings as tabs, the twelve
++ * languages as chips, and one card per repository: owner avatar, name, a two-line description, the language
++ * with its dot, stars with the week's gain, forks, when it was last pushed, topics and a link to GitHub.
++ *
++ * The cards are a CSS grid, one column on a phone and three on a desktop; every name and description wraps
++ * or is clamped, so no card pushes the page sideways at 320–430 px. The page is complete without JavaScript.
++ */
++const kit = require('openvibe-shared/kit');
++const glyphs = require('openvibe-shared/glyphs');
++const { esc } = require('../render/html');
++
++// The same twelve GitHub searches Sources keeps (server/adapters/github.js in OpenVibe.Sources). A chip
++// that filters a list and a search alike; a language not here is still in a card's own language line.
++const LANGUAGES = Object.freeze(['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin']);
++const LANG_COLOR = {
++ JavaScript: '#f1e05a', TypeScript: '#3178c6', Python: '#3572A5', Go: '#00ADD8', Rust: '#dea584', Java: '#b07219',
++ 'C++': '#f34b7d', 'C#': '#178600', PHP: '#4F5D95', Ruby: '#701516', Swift: '#F05138', Kotlin: '#A97BFF',
++};
++const TABS = Object.freeze([
++ { key: 'trending', label: 'Trending today', fa: 'fa-fire' },
++ { key: 'week', label: 'This week', fa: 'fa-calendar' },
++ { key: 'hot', label: 'Hot', fa: 'fa-arrow-trend-up' },
++ { key: 'new', label: 'New', fa: 'fa-plus' },
++ { key: 'top', label: 'Top', fa: 'fa-star' },
++]);
++
++/** The glyph for a Font Awesome name, or nothing when the registry has none. */
++const icon = (fa) => { const name = glyphs.fromFa(fa); return name ? glyphs.svg(name) : ''; };
++const count = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString('en-US') : '—');
++const when = (iso, now) => {
++ const t = Date.parse(iso || '');
++ if (!Number.isFinite(t)) return 'date not listed';
++ const s = Math.max(0, Math.round((now - t) / 1000));
++ if (s < 90) return 'just now';
++ if (s < 3600) return `${Math.round(s / 60)} min ago`;
++ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ if (s < 30 * 86400) return `${Math.round(s / 86400)} d ago`;
++ return new Date(t).toISOString().slice(0, 10);
++};
++
++function avatar(repo) {
++ const owner = String(repo.full_name || '').split('/')[0] || '?';
++ if (repo.owner_avatar_url) return `<img class="cd-avatar" src="${esc(repo.owner_avatar_url)}" alt="" width="40" height="40" loading="lazy" decoding="async">`;
++ return `<span class="cd-avatar cd-avatar--letter" aria-hidden="true">${esc(owner.slice(0, 1).toUpperCase())}</span>`;
++}
++
++function card(repo, now) {
++ const gain = Number.isFinite(Number(repo.stars_gained_7d)) && Number(repo.stars_gained_7d) > 0 ? ` <span class="cd-gain">+${count(repo.stars_gained_7d)} this week</span>` : '';
++ const topics = (Array.isArray(repo.topics) ? repo.topics : []).slice(0, 5);
++ const lang = repo.language || null;
++ const dot = lang ? `<span class="cd-dot" style="--cd-lang:${esc(LANG_COLOR[lang] || 'var(--ovk-accent)')}" aria-hidden="true"></span>` : '';
++ return `<li class="cd-card">
++<header class="cd-card-head">${avatar(repo)}<div class="cd-card-id"><a class="cd-name" href="${esc(repo.url)}" rel="noopener noreferrer">${esc(repo.full_name)}</a><span class="cd-owner">${esc(String(repo.full_name || '').split('/')[0] || '')}</span></div></header>
++<p class="cd-desc">${repo.description ? esc(repo.description) : '<span class="cd-muted">No description.</span>'}</p>
++<p class="cd-stats"><span class="cd-stat" title="Stars">${icon('fa-star')}<b>${count(repo.stars)}</b>${gain}</span><span class="cd-stat" title="Forks">${icon('fa-code-branch') || ''}<b>${count(repo.forks)}</b> forks</span>${lang ? `<span class="cd-lang">${dot}${esc(lang)}</span>` : ''}</p>
++${topics.length ? `<ul class="cd-topics" aria-label="Topics">${topics.map((t) => `<li>${esc(t)}</li>`).join('')}</ul>` : ''}
++<footer class="cd-foot"><span>Updated ${esc(when(repo.pushed, now))}</span><a class="cd-open" href="${esc(repo.url)}" rel="noopener noreferrer">GitHub →</a></footer>
++</li>`;
++}
++
++function cards(repos, now) {
++ if (!repos.length) return kit.empty({ icon: 'code', title: 'No repositories here yet', text: 'The GitHub source is still filling the list, or this filter has no match. Try another language or list.' });
++ return `<ul class="cd-grid">${repos.map((r) => card(r, now)).join('')}</ul>`;
++}
++
++function tabLinks(list, lang, q) {
++ return TABS.map((t) => {
++ const href = `/code?list=${encodeURIComponent(t.key)}${lang ? `&lang=${encodeURIComponent(lang)}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
++ return `<a class="cd-tab${t.key === list ? ' is-on' : ''}" href="${href}"${t.key === list ? ' aria-current="page"' : ''}>${icon(t.fa)}${esc(t.label)}</a>`;
++ }).join('');
++}
++
++function languageLinks(list, lang, q) {
++ const all = `/code?list=${encodeURIComponent(list)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
++ const chips = [`<a class="cd-chip${lang ? '' : ' is-on'}" href="${all}">All languages</a>`]
++ .concat(LANGUAGES.map((l) => {
++ const href = `/code?list=${encodeURIComponent(list)}&lang=${encodeURIComponent(l)}${q ? `&q=${encodeURIComponent(q)}` : ''}`;
++ return `<a class="cd-chip${l === lang ? ' is-on' : ''}" href="${href}">${esc(l)}</a>`;
++ }));
++ return `<nav class="cd-langs" aria-label="Language">${chips.join('')}</nav>`;
++}
++
++/**
++ * o: { list, lang, q, repos, total, now, stale, unavailable, search }
++ */
++function codePage({ list = 'trending', lang = null, q = '', repos = [], total = null, now = Date.now(), stale = false, unavailable = false, search = false, notice: extraNotice = '' }) {
++ const notice = unavailable
++ ? kit.notice({ kind: 'warn', text: 'The repository list is not reachable right now. It is read from OpenVibe.Sources; reload in a moment.' })
++ : extraNotice ? kit.notice({ kind: 'info', text: extraNotice })
++ : stale ? kit.notice({ kind: 'info', text: 'These are the last repositories read from OpenVibe.Sources; the live read did not answer just now.' }) : '';
++ const title = search ? `Repositories for “${q}”` : (TABS.find((t) => t.key === list) || TABS[0]).label;
++ const sub = search && total != null ? `${count(total)} GitHub matches · repositories only, no code copied` : 'Trending, new and most-starred repositories on GitHub, with the star gains that make them hot.';
++ const results = `<section class="cd-results" aria-labelledby="cd-results-title"><div class="cd-results-head"><h2 id="cd-results-title">${search ? 'Search results' : esc(title)}</h2><span class="cd-count">${repos.length} shown</span>${search ? '<a class="cd-clear" href="/code">Clear search</a>' : ''}</div>${cards(repos, now)}</section>`;
++ return `<div class="ex-wrap ex-page cd">
++${kit.head({ title: 'Code', sub, actions: [kit.button({ href: '/api#code', label: 'Code API', kind: 'quiet', icon: 'code' })] })}
++<section class="cd-find" aria-label="Search repositories">
++${kit.find({ action: '/code', name: 'q', placeholder: 'Search repositories: stars:>500 topic:ai', value: q, icon: 'search', maxlength: 200, hidden: lang ? { lang } : {} })}
++<p class="cd-hint">GitHub's own search syntax works: <code>stars:&gt;500 topic:ai</code>, <code>language:rust</code>, <code>pushed:&gt;2026-10-01</code>. Code is linked, never copied.</p>
++</section>
++<nav class="cd-tabs" aria-label="Repository list">${tabLinks(list, lang, q)}</nav>
++${languageLinks(list, lang, q)}
++${notice}
++${results}
++</div>`;
++}
++
++module.exports = { codePage, LANGUAGES, TABS };
ddiff --git a/server/views/deep-dive.js b/server/views/deep-dive.js
iindex b7b85a7..3007a48 100644
---- a/server/views/deep-dive.js
++++ b/server/views/deep-dive.js
@@@ -15,7 +15,7 @@ const kit = require('openvibe-shared/kit');
const { esc, raw } = require('../render/html');
const STEP_LABELS = { plan: 'Planning', search: 'Searching', choose: 'Choosing sites', capture: 'Capturing', analyse: 'Analysing', synthesise: 'Writing it up', report: 'Writing it up' };
--const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };
++const STEP_NOTES = { plan: 'The questions an expert would ask, and the searches that answer them.', search: 'MegaSource, the search engines, GitHub repositories through Exchange\'s Code search, and our own rules and components.', choose: 'The sites worth capturing, with a reason each. Add or drop any before Capture.', capture: 'Each site at 390 and 1280 px, light and dark where it supports dark, full page after a slow scroll.', analyse: 'A vision model reads each screenshot; a text model reads the DOM facts and the words.', synthesise: 'Patterns across sites, the gap, reference tokens, and recommendations checked against our rules.', report: 'The report, its evidence and the receipt.' };
const price = (n) => `$${Number(n || 0).toFixed(2)}`;
const when = (iso) => { if (!iso) return '—'; try { return new Date(iso).toISOString().slice(0, 16).replace('T', ' ') + ' UTC'; } catch { return String(iso); } };
const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u || ''); } };
@@@ -59,7 +59,7 @@ const chips = (words) => `<ul class="dd-chips">${words.map((w) => `<li>${esc(w)}
function whatItDoes() {
const step = (icon, title, text, extra = '') => `<li><span class="dd-what-ic" aria-hidden="true">${kit.icon(icon)}</span><div><strong>${title}</strong><p>${text}</p>${extra}</div></li>`;
return kit.section({ title: 'What a deep dive does', body: `<ol class="dd-what">
--${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub, design write-ups, and our own rules register and component registry.')}
++${step('search', 'Search every way we have.', 'Our research sources, Exchange\'s web, picture and icon engines, open-source projects on GitHub through <a href="/code">Code</a>, design write-ups, and our own rules register and component registry.')}
${step('check', 'Choose the sites worth a look.', 'A model ranks them with a reason each; you can add or drop any before Capture (Quick goes straight on).')}
${step('image', 'Capture them properly.', '390 and 1280 px, dark and light where the site supports it, the whole page after a slow scroll so lazy sections load, and the DOM facts:',
`${chips(MEASURED)}<p class="dd-what-sub">Expert adds</p>${chips(EXPERT_ADDS)}`)}
@@@ -70,6 +70,18 @@ ${step('book', 'Show the evidence.', 'Every claim names its site, capture and th
<p class="ovk-note">The price is shown before it starts and held; cancelling stops the spend. No paid screenshot service: our own Chrome, one page budget at a time.</p>` });
}
++/**
++ * Code and tools are evidence too: a deep dive about a library, a protocol or a tool should be able to cite
++ * the repositories it found. The repositories are Sources' (Exchange never reads GitHub itself), and this
++ * panel is the way from a deep dive into the same rankings and search the Code page uses.
++ */
++function codeSources() {
++ return kit.section({ title: 'Code and tools', note: 'Repositories are cited like any other source: by their GitHub URL, with the date they were read.', body:
++ `<p class="ovk-prose">A repository is evidence for how a tool is built and how active it is. The rankings and the search are read through <a href="/code">Code</a>: trending today, hot by stars gained, new and most starred, or GitHub's own query syntax.</p>`
++ + kit.find({ action: '/code', name: 'q', placeholder: 'Find a repository: stars:>500 topic:ai', icon: 'search', maxlength: 200 })
++ + `<p class="ovk-note">No repository code or README is copied; a card keeps what the repository states about itself and links to it.</p>` });
++}
++
function homePage({ viewer, depths = [], capture = null, error = null }) {
const signedIn = Boolean(viewer && viewer.kind === 'user');
const cards = (depths && depths.length ? depths : [
@@@ -87,7 +99,7 @@ ${depthCards(cards)}
: signIn();
return `<div class="ex-wrap dd">${kit.head({ title: 'Deep dive', sub: 'Expert design research: we search everywhere, capture the best sites at phone and computer sizes, and tell you what works and why.' })}${form}${signedIn ? '' : `<section class="ovk-section"><h2>The depths and their prices</h2>${depthList(cards)}</section>`}`
+ (capture && capture.enabled === false ? `<p class="dd-error">Capture is off on this host right now (drill mode or Chrome missing), so a deep dive cannot start.</p>` : '')
-- + whatItDoes() + `</div>`;
++ + whatItDoes() + codeSources() + `</div>`;
}
function choosing(row, id) {
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex 0d72044..5be66b4 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -223,6 +223,7 @@ ${models.length ? `<p class="ex-more"><a href="/models">All ${models.length} mod
<div class="ex-lines">
${lineCard({ href: '/arena', name: 'The Arena', text: 'One prompt, two unnamed models, one vote: then both names and their receipts.', icon: 'swap', tone: 'accent' })}
${lineCard({ href: '/rankings', name: 'Rankings by task', text: 'What people prefer for code, writing, chat and reasoning, beside the price, speed and uptime. Worth it, measured.', icon: 'trophy', tone: 'accent' })}
++${lineCard({ href: '/code', name: 'Code', text: 'GitHub repositories trending today, hot by stars gained, and a deep search with GitHub\'s own syntax.', icon: 'code', tone: 'accent' })}
${lineCard({ href: '/uncensored', name: 'Uncensored (18+)', text: 'Abliterated models for adults, with the floor that holds on every request.', icon: 'shield', tone: 'warn' })}
${lineCard({ href: '/why', name: 'Why this model', text: 'Every answer carries its receipt: the objective, the candidates, their cost and latency, and why the others did not answer.', icon: 'help', tone: 'accent' })}
${lineCard({ href: '/start', name: 'Start building', text: 'One key, one endpoint and the SDK you already use; four steps from a key to a receipt.', icon: 'code', tone: 'accent' })}
@@@ -522,6 +523,13 @@ ${keyPrivacy && keyPrivacy.note ? kit.notice({ kind: 'info', text: keyPrivacy.no
return `<div class="ex-wrap ex-page">
${kit.head({ title: 'The API', sub: 'One endpoint. OpenAI and Anthropic compatible.', actions: [kit.button({ href: CHAT, label: 'Chat', icon: 'chat', kind: 'quiet' })] })}
${panel({ title: 'Batches, at half price', body: `<p class="ex-prose">Half price. Answered within your deadline when spare low-cost capacity is free; if none comes free in time, the prompt expires and you pay nothing.</p>` })}
++${panel({ title: 'Code repositories', id: 'code', body: `
++<p class="ex-prose"><code>GET /api/v1/code/repos?list=trending|week|month|hot|new|top&lang=&topic=&limit=</code> is the cached GitHub repository ranking read through OpenVibe.Sources: <code>trending</code> is repositories created today by stars, <code>week</code> and <code>month</code> the same for 7 and 30 days, <code>hot</code> the most stars gained in 24 hours and 7 days (never totals), <code>new</code> the newest repositories and <code>top</code> the all-time most starred. <code>lang</code> and <code>topic</code> filter exactly; <code>limit</code> is 1–100 (default 30).</p>
++<p class="ex-prose"><code>GET /api/v1/code/search?q=&lang=&sort=stars|updated|best&page=</code> is a live GitHub repository search through the same source, cached ten minutes and rate-limited per person or address. GitHub's own query syntax passes through: <code>q=stars:&gt;500 topic:ai</code>, <code>q=language:rust pushed:&gt;2026-10-01</code>. Both endpoints need no key and no session, and both answer <code>Cache-Control: public</code>.</p>
++<p class="ex-prose">Shape: <code>{ items: [{ full_name, url, description, language, topics, stars, stars_gained_1d, stars_gained_7d, forks, open_issues, license, created, pushed, owner_avatar_url, homepage }] }</code>. A gain with no reading yet is <code>null</code>, never 0. The browse page is <a href="/code">Code</a>.</p>
++${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/repos?list=week&limit=3' \\\n -H 'Accept: application/json'`, id: 'code-list-curl' })}
++${kit.snippet({ code: `curl 'https://openvibe.exchange/api/v1/code/search?q=stars:%3E500%20topic:ai' \\\n -H 'Accept: application/json'`, id: 'code-search-curl' })}
++` })}
${panel({ title: 'Compute prices', id: 'compute', body: `
<p class="ex-prose"><code>GET /api/compute/prices</code> is the reviewed compute tariff behind <a href="/compute">Compute prices</a>, as JSON. It needs no key and no session: the answer is public and carries <code>Access-Control-Allow-Origin: *</code>, so another origin can read it in a browser. It is cached for 60 seconds (<code>Cache-Control: public, max-age=60</code>). The prices are provider tariffs, not quotes: each row keeps the date it was checked (<code>checked_at</code>) and the date it must be reviewed again (<code>expires_at</code>).</p>
<p class="ex-prose">Shape: <code>{ checked_at, prices: [{ id, provider, region?, kind: "cpu" | "gpu", cpu?, ram_mb?, gpu?, gpu_mb?, currency, hourly, per_second, billing_seconds, promotion?, promotion_expires?, interruptible?, expires_at, source }] }</code>. <code>hourly</code> and <code>per_second</code> are USD; <code>expires_at</code> is epoch milliseconds; <code>source</code> is the provider's own price page. Storage, network and plan fees are not included.</p>
ddiff --git a/test/code-client.test.js b/test/code-client.test.js
nnew file mode 100644
iindex 0000000..b5622e9
---- /dev/null
++++ b/test/code-client.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++// The Sources client: its own client-credentials token, the loopback read, the two caches, and the last copy
++// marked stale when the source stops answering. No network: both fetches are stubs.
++const assert = require('assert');
++const { createCode } = require('../server/code');
++const configLib = require('../server/config');
++
++(async () => {
++ const config = configLib.load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://network.test', OV_SOURCES_URL: 'http://sources.test', OV_OAUTH_CLIENT_ID: 'exchange', OV_OAUTH_CLIENT_SECRET: 'secret' });
++ let now = Date.parse('2026-11-12T12:00:00Z');
++ const calls = [];
++ const tokenCalls = [];
++ const tokenFetch = async (url, opts) => {
++ tokenCalls.push({ url: String(url), body: String(opts.body || '') });
++ return new Response(JSON.stringify({ access_token: 'tok', token_type: 'Bearer', expires_in: 300 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const fetchImpl = async (url, opts) => {
++ calls.push({ url: String(url), auth: opts.headers.authorization });
++ if (url.includes('/api/v1/code/search')) {
++ return new Response(JSON.stringify({ items: [{ full_name: 'a/b' }], total: 1, page: 1, sort: 'best', q: 'ai', lang: null, cached: false, stale: false }), { status: 200, headers: { 'content-type': 'application/json' } });
++ }
++ return new Response(JSON.stringify({ list: 'week', items: [{ full_name: 'a/b' }], count: 1 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const code = createCode({ config, fetchImpl, tokenFetch, now: () => now, log: { warn() {} } });
++ assert.strictEqual(code.configured, true);
++
++ const one = await code.repos({ list: 'week', limit: 3 });
++ assert.strictEqual(one.items[0].full_name, 'a/b');
++ assert.deepStrictEqual(calls[0], { url: 'http://sources.test/api/v1/code/repos?list=week&limit=3', auth: 'Bearer tok' });
++ assert.ok(tokenCalls.some((c) => c.body.includes('grant_type=client_credentials')), 'a client-credentials token is asked for');
++ const two = await code.repos({ list: 'week', limit: 3 });
++ assert.strictEqual(two.cached, true);
++ assert.strictEqual(calls.length, 1, 'the rankings are cached');
++ now += 61_000;
++ await code.repos({ list: 'week', limit: 3 });
++ assert.strictEqual(calls.length, 2, 'and expire after a minute');
++
++ const s1 = await code.search({ q: 'ai' });
++ assert.strictEqual(s1.items.length, 1);
++ const s2 = await code.search({ q: 'ai' });
++ assert.strictEqual(s2.cached, true);
++ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 1, 'a search is cached ten minutes');
++ now += 601_000;
++ await code.search({ q: 'ai' });
++ assert.strictEqual(calls.filter((c) => c.url.includes('/search')).length, 2);
++
++ // Sources down: the last copy is served for five minutes, marked stale; after that the error is honest.
++ const down = async () => { throw new Error('down'); };
++ const offline = createCode({ config, fetchImpl: down, tokenFetch, now: () => now, log: { warn() {} } });
++ await assert.rejects(() => offline.repos({ list: 'week' }), /unreachable|not answering/);
++ const burst = offline.allow('usr_test');
++ assert.strictEqual(burst.allowed, true);
++ process.stdout.write('code client: 16 checks passed\n');
++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
ddiff --git a/test/code.test.js b/test/code.test.js
nnew file mode 100644
iindex 0000000..da2bd54
---- /dev/null
++++ b/test/code.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++// /code and the public code API: the tabs, the language chips, a card's fields, search, the API shapes and
++// cache headers, the per-actor search limit, and an honest page when Sources does not answer.
++const assert = require('assert');
++const { boot } = require('./helpers/boot');
++
++const NOW = Date.now();
++const ITEMS = [
++ {
++ full_name: 'acme/hot-repo', url: 'https://github.com/acme/hot-repo', description: 'A tiny tool that does one thing well.',
++ language: 'Rust', topics: ['ai', 'tools'], stars: 123456, stars_gained_1d: 300, stars_gained_7d: 1200,
++ forks: 42, open_issues: 7, license: 'MIT', created: new Date(NOW - 3 * 86400000).toISOString(),
++ pushed: new Date(NOW - 3 * 86400000).toISOString(), owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4',
++ homepage: 'https://acme.example', source: 'github',
++ },
++ {
++ full_name: 'beta/young', url: 'https://github.com/beta/young', description: '<b>bold</b> & new',
++ language: 'Python', topics: [], stars: 250, stars_gained_1d: null, stars_gained_7d: null,
++ forks: 2, open_issues: 0, license: null, created: new Date(NOW - 3600000).toISOString(),
++ pushed: new Date(NOW - 3600000).toISOString(), owner_avatar_url: null, homepage: null, source: 'github',
++ },
++];
++
++function fakeCode() {
++ const calls = { repos: [], search: [], allow: 0 };
++ const code = {
++ configured: true,
++ calls,
++ allow: () => { calls.allow++; return { allowed: true }; },
++ async repos(args) {
++ calls.repos.push(args);
++ return { generated_at: new Date(NOW).toISOString(), list: args.list, lang: args.lang, count: ITEMS.length, source: { key: 'github', status: 'healthy', stale: false }, items: ITEMS, cached: false };
++ },
++ async search(args) {
++ calls.search.push(args);
++ return { items: [ITEMS[0]], total: 9341, incomplete: false, page: args.page, sort: args.sort, q: args.q, lang: args.lang, cached: false, stale: false };
++ },
++ };
++ return code;
++}
++
++(async () => {
++ const code = fakeCode();
++ const t = await boot({ code });
++ try {
++ // The page: search box, five tabs, twelve languages and All, three-column-capable cards.
++ let r = await t.get('/code');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Search repositories: stars:&gt;500 topic:ai|Search repositories: stars:>500 topic:ai/);
++ for (const label of ['Trending today', 'This week', 'Hot', 'New', 'Top']) assert.ok(r.text.includes(label), label);
++ for (const l of ['JavaScript', 'TypeScript', 'Python', 'Go', 'Rust', 'Java', 'C++', 'C#', 'PHP', 'Ruby', 'Swift', 'Kotlin', 'All languages']) assert.ok(r.text.includes(l), l);
++ assert.match(r.text, /acme\/hot-repo/);
++ assert.match(r.text, /A tiny tool that does one thing well\./);
++ assert.match(r.text, /123,456/);
++ assert.match(r.text, /\+1,200 this week/);
++ assert.match(r.text, /42<\/b> forks/);
++ assert.match(r.text, /3 d ago/);
++ assert.match(r.text, /class="cd-dot"/);
++ assert.match(r.text, /https:\/\/avatars\.githubusercontent\.com\/u\/1/);
++ assert.match(r.text, /class="cd-topics"[^>]*><li>ai<\/li><li>tools<\/li>/);
++ assert.match(r.text, /href="https:\/\/github\.com\/acme\/hot-repo"/);
++ assert.match(r.text, /&lt;b&gt;bold&lt;\/b&gt; &amp; new/, 'a description is escaped');
++ assert.match(r.text, /href="\/code\?list=week"/);
++ assert.deepStrictEqual(code.calls.repos[0], { list: 'trending', lang: null, topic: null, limit: 24 });
++
++ // A tab and a language filter are links, not client state.
++ r = await t.get('/code?list=hot&lang=Rust');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /list=hot&amp;lang=Rust|list=hot&lang=Rust/);
++ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'hot', lang: 'Rust', topic: null, limit: 24 });
++
++ // Search is GitHub's own syntax, passed through; the results keep the card shape.
++ r = await t.get('/code?q=stars:>500%20topic:ai&lang=Rust');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: 'Rust', sort: 'best', page: 1 });
++ assert.match(r.text, /Search results/);
++ assert.match(r.text, /9,341 GitHub matches/);
++ assert.match(r.text, /Clear search/);
++
++ // Public API: list, filters, cache header and shape.
++ r = await t.get('/api/v1/code/repos?list=week&lang=Rust&limit=3');
++ assert.strictEqual(r.status, 200);
++ const body = r.json();
++ assert.deepStrictEqual([body.list, body.count], ['week', 2]);
++ assert.strictEqual(body.items[0].full_name, 'acme/hot-repo');
++ assert.strictEqual(body.items[0].stars_gained_7d, 1200);
++ assert.match(r.headers.get('cache-control'), /max-age=60/);
++ assert.strictEqual((await t.get('/api/v1/code/repos?list=weather')).status, 422);
++ r = await t.get('/api/v1/code/repos?list=month&lang=Rust&topic=ai&limit=200');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(code.calls.repos.at(-1), { list: 'month', lang: 'Rust', topic: 'ai', limit: 100 });
++
++ // Public search: cached ten minutes, same shape, and a missing q is a 422.
++ r = await t.get('/api/v1/code/search?q=stars:>500%20topic:ai&sort=stars&page=2');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(code.calls.search.at(-1), { q: 'stars:>500 topic:ai', lang: null, sort: 'stars', page: 2 });
++ assert.strictEqual(r.json().total, 9341);
++ assert.match(r.headers.get('cache-control'), /max-age=600/);
++ assert.strictEqual((await t.get('/api/v1/code/search')).status, 422);
++ assert.strictEqual((await t.get('/api/v1/code/search?q=ai&sort=fame')).status, 422);
++
++ // Per-actor limit: the route answers 429 with Retry-After, never a GitHub call.
++ const before = code.calls.search.length;
++ code.allow = () => ({ allowed: false, retryAfter: 17 });
++ r = await t.get('/api/v1/code/search?q=ai');
++ assert.strictEqual(r.status, 429);
++ assert.strictEqual(r.headers.get('retry-after'), '17');
++ assert.strictEqual(r.json().code, 'code.rate_limited');
++ assert.strictEqual(code.calls.search.length, before);
++
++ // Sources unavailable: the page says so and the API answers 503, never an empty list dressed as live.
++ code.repos = async () => { throw new Error('down'); };
++ r = await t.get('/api/v1/code/repos?list=trending');
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.json().code, 'code.unavailable');
++ r = await t.get('/code');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /repository list is not reachable right now/);
++
++ // The deep dive names repositories as a source and links the same search.
++ r = await t.get('/deep-dive');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /Code and tools/);
++ assert.match(r.text, /Repositories are cited like any other source/);
++ assert.match(r.text, /action="\/code"/);
++
++ // Discovery carries the page and the two endpoints.
++ r = await t.get('/llms.txt');
++ assert.strictEqual(r.status, 200);
++ assert.match(r.text, /\/code/);
++ assert.match(r.text, /\/api\/v1\/code\/repos/);
++ r = await t.get('/sitemap.xml');
++ assert.match(r.text, /https:\/\/openvibe\.exchange\/code/);
++ process.stdout.write('code: 32 checks passed\n');
++ } finally { await t.close(); }
++})().catch((e) => { process.stderr.write(String(e && e.stack || e) + '\n'); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex fbf3374..bb8f48a 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -45,7 +45,7 @@ async function boot(opts = {}) {
// no test ever waits on the network (the notes tests inject their own, in test/helpers or the file itself).
const emptyMemory = async () => ({ ok: true, status: 200, text: async () => '{"items":[]}', json: async () => ({ items: [] }) });
const emptyRatings = async () => new Response(JSON.stringify({ board: { minimum_samples: 20, rows: [] } }), { headers: { 'content-type': 'application/json' } });
-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl });
++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend, ai: opts.ai, aiFetchImpl: opts.aiFetchImpl || emptyMemory, actor: opts.actor, actorFetchImpl: opts.actorFetchImpl, bench: opts.bench, benchFetchImpl: opts.benchFetchImpl || emptyRatings, analysis: opts.analysis, openapiFetch: opts.openapiFetch, indexnow: opts.indexnow, fetchImpl: opts.fetchImpl, code: opts.code, sourcesFetchImpl: opts.sourcesFetchImpl });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;