feat(render): add one-click deploy to render support (#2804)
Some checks are pending
Scorecard / Scorecard analysis (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Skill copy sync / shipped skills drift guard (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run

This commit is contained in:
Shifra Williams 2026-08-05 17:19:44 -07:00 committed by GitHub
parent a033b04c46
commit f2717c6a7c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2482 additions and 203 deletions

View file

@ -125,5 +125,7 @@ ENV GITNEXUS_HOME=/data/gitnexus \
EXPOSE 4747
# Bind to 0.0.0.0 so the server is reachable from the host's mapped port.
CMD ["node", "gitnexus/dist/cli/index.js", "serve", "--host", "0.0.0.0", "--port", "4747"]
# Bind 0.0.0.0 for the host's mapped port, honoring an injected $PORT (Render
# sets one). `sh -c` expands it; `exec` keeps the server PID 1 so SIGTERM still
# reaches it. Platforms can rely on this instead of a dockerCommand override.
CMD ["sh", "-c", "exec gitnexus serve --host 0.0.0.0 --port \"${PORT:-4747}\""]

View file

@ -80,6 +80,28 @@ That's it. `analyze` indexes the codebase, installs agent skills, registers Clau
</details>
### Deploy to Render
Deploy GitNexus in one click:
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
The Blueprint creates two services. `gitnexus-server` runs `gitnexus serve` as a private service: no public URL, reachable only over Render's private network, with a persistent disk for indexes and cloned repos. `gitnexus-web` is the public one. It serves the UI and reverse-proxies `/api/*` to the server, so the browser talks to a single origin.
At the Blueprint's defaults this runs about **$35/month**: $25 for the server's `standard` instance, $7 for the web service's `starter` instance, and $2.50 for the 10 GB disk. See [Render's pricing](https://render.com/pricing) for other plans.
The deploy generates an access token, and the UI asks for it on first use:
1. Open the `gitnexus-web` service in your [Render dashboard](https://dashboard.render.com/).
2. Copy `GITNEXUS_SERVE_AUTH_TOKEN` from its **Environment** tab.
3. Load the site and paste the token into the prompt (or the settings panel).
Every `/api/*` request carries that token as a header, and the proxy answers `401` without it. The browser keeps it in `sessionStorage`, so a new tab asks again. To rotate it, edit the environment variable and redeploy.
The proxy strips `Origin` before forwarding, so the server's CSRF guard does nothing for proxied traffic; it passes `Origin`-less requests through by design. The token is the only control on this deploy, not a second layer behind the guard. Anyone holding it can read every indexed repo. See [SECURITY.md](SECURITY.md#hosted-deploys-on-render).
Indexing is memory-bound. If `gitnexus-server` runs out of memory on a large repo, raise its `plan`, which sets available RAM: `standard` is 2 GB, `pro` is 4 GB. Raise `sizeGB` only if the disk fills with clones and indexes.
## Two Ways to Use GitNexus
| | **CLI + MCP** (recommended) | **Web UI** |

View file

@ -51,6 +51,19 @@ If you fork GitNexus or self-host it, we recommend enabling the following in you
- **Secret scanning** and **Push protection** — blocks pushes that introduce known secret patterns. Defense-in-depth on top of the in-CI Gitleaks scan documented below.
- **Code scanning** — surfaces SARIF results from CodeQL, Trivy, Scorecard, and zizmor in one place.
### Hosted Deploys on Render
The `render.yaml` Blueprint (see the README's **Deploy to Render**) puts `gitnexus serve` on a **private service** with no public URL, and a public web service in front of it that reverse-proxies `/api/*`. What that does and does not protect:
- **The web service is public and its URL is discoverable.** `onrender.com` hostnames appear in certificate transparency logs. Treat the URL as known rather than secret.
- **The generated `GITNEXUS_SERVE_AUTH_TOKEN` is the only access control.** The proxy rejects any `/api/*` request without it with a `401` before forwarding. Rotate it by editing the environment variable on the `gitnexus-web` service and redeploying.
- **The CSRF guard is inert on this path.** The proxy strips `Origin` before forwarding, so the server's write-origin guard does nothing for proxied traffic — it passes `Origin`-less requests through by design. The token is not a second layer behind the guard.
- **Anyone holding the token can read every indexed repo's source.** These routes carry no origin guard, and the first three carry no rate limiter either: `GET /api/repos`, `GET /api/graph`, `POST /api/query`, `GET /api/file`, `GET /api/grep`. Whoever has the token can also index and delete repositories.
- **`POST /api/mcp` rides the same path.** `serve` mounts the MCP handler via `mountMCPEndpoints`, and `createStreamableHttpHandler` is called with no `authToken` — a **pre-existing** gap in `serve` itself, not something this deploy introduces. On Render it is closed only by the edge token and the private network. A `serve` bound directly to a public interface has no such cover.
- **Rate limits bound cost, not access.** They cap what a token holder can spend; they do not decide who gets in.
Do not hand the URL out as a public demo. A token holder has read access to everything the deploy has indexed.
## Automated Scans Running in CI
This repository runs the following scans automatically. Findings appear under the repository's **Security → Code scanning** tab.

View file

@ -1,5 +1,8 @@
import { timingSafeEqual } from 'node:crypto';
import { writeSync } from 'node:fs';
import { open } from 'node:fs/promises';
import { createServer } from 'node:http';
import { createServer, request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { extname, isAbsolute, normalize, relative, resolve, sep } from 'node:path';
const host = '0.0.0.0';
@ -22,18 +25,430 @@ function jsonForScriptTag(obj) {
.replace(/&/g, '\\u0026');
}
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? null;
if (rawBackendUrl && !isValidUrl(rawBackendUrl)) {
const safeRaw = rawBackendUrl.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 200);
console.warn(
`[gitnexus-web] GITNEXUS_BACKEND_URL "${safeRaw}" is not a valid http/https URL -- ignoring.`,
// Warnings echo operator input back, so strip control characters (log forging)
// and cap the length first.
function sanitizeForLog(value) {
return (
String(value)
// The line-break strip is redundant with the range below, but CodeQL's
// js/log-injection recognizes only this shape as a sanitizer: a global
// replace of a literal \n with the empty string.
.replace(/\n/g, '')
.replace(/\r/g, '')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.slice(0, 200)
);
}
const backendUrl = rawBackendUrl && isValidUrl(rawBackendUrl) ? rawBackendUrl : null;
// console.error is asynchronous when stderr is a pipe, so pairing it with
// process.exit can drop the one message explaining the refusal. writeSync isn't.
function exitWithRefusal(message) {
writeSync(2, `${message}\n`);
process.exit(1);
}
// `value` if it's a usable http/https URL, else null + a warning naming `label`.
// `rawForLog` lets a caller that normalized first echo back the operator's input.
function validHttpUrl(label, value, rawForLog = value) {
if (!value) return null;
if (isValidUrl(value)) return value;
const safeRaw = sanitizeForLog(rawForLog);
console.warn(`[gitnexus-web] ${label} "${safeRaw}" is not a valid http/https URL -- ignoring.`);
return null;
}
// Numeric env var. Every consumer below reads <= 0 as "disabled", so obeying a
// typo like -1 would switch a timeout off silently. Warn and use the default.
function numberFromEnv(label, fallback, min = 0) {
const raw = process.env[label];
if (raw === undefined || raw === '') return fallback;
const n = Number(raw);
if (!Number.isFinite(n)) {
console.warn(
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is not a number -- using ${fallback}.`,
);
return fallback;
}
if (n < min) {
console.warn(
`[gitnexus-web] ${label} "${sanitizeForLog(raw)}" is below the minimum ${min} -- using ${fallback}.`,
);
return fallback;
}
return n;
}
// Falls back to RENDER_EXTERNAL_URL so a Render web service hands the browser
// its own public origin — same-origin API calls via the proxy below, no config.
const backendUrlVar =
process.env.GITNEXUS_BACKEND_URL !== undefined ? 'GITNEXUS_BACKEND_URL' : 'RENDER_EXTERNAL_URL';
const rawBackendUrl = process.env.GITNEXUS_BACKEND_URL ?? process.env.RENDER_EXTERNAL_URL ?? null;
const backendUrl = validHttpUrl(backendUrlVar, rawBackendUrl);
const configScript = backendUrl
? `<script>window.__GITNEXUS_CONFIG__=${jsonForScriptTag({ backendUrl })};</script>`
: '';
// Optional same-origin reverse proxy for the API server. On a split deploy
// (public web service, private API) the browser must reach the API without a
// cross-origin request, since its CORS allowlist and write-route guard only
// admit same-host origins. So the browser targets THIS origin and we forward
// /api/* to GITNEXUS_UPSTREAM_URL. Unset → no proxy (docker-compose default).
// A scheme-less host:port — what Render's `fromService: hostport` yields —
// gets http:// prepended.
const rawUpstream = process.env.GITNEXUS_UPSTREAM_URL;
const rawUpstreamUrl = rawUpstream
? /^https?:\/\//.test(rawUpstream)
? rawUpstream
: `http://${rawUpstream}`
: null;
const upstreamBase = validHttpUrl('GITNEXUS_UPSTREAM_URL', rawUpstreamUrl, rawUpstream);
// The one origin this proxy will ever connect to (see proxyToUpstream).
const upstreamOrigin = upstreamBase ? new URL(upstreamBase).origin : null;
// The Bearer token every /api/* request must carry. The private upstream has no
// auth of its own and loses its Origin guard one hop below (see
// proxyToUpstream), so the gate belongs here. The browser holds it — never
// inject it next to `backendUrl`. Blank-is-absent follows resolveAuthToken
// (gitnexus/src/mcp/http-transport.ts).
const authToken = process.env.GITNEXUS_SERVE_AUTH_TOKEN?.trim() || null;
// Mirrors the non-loopback refusal in http-transport.ts (startMcpHttpServer),
// relocated because the trust boundary is here: an unguarded `serve` behind a
// private service is legitimate, an unguarded public proxy is not.
if (upstreamBase && !authToken) {
exitWithRefusal(
'[gitnexus-web] Refusing to start: GITNEXUS_UPSTREAM_URL is set without ' +
'GITNEXUS_SERVE_AUTH_TOKEN. The proxy would expose every indexed repo — ' +
'index, read source, and delete — to anyone with this URL. Set a token, ' +
'or unset GITNEXUS_UPSTREAM_URL to serve static assets only.',
);
}
// Rejected requests never reach the upstream limiter, so guesses are free. A
// throttle would add per-address state to a stateless proxy and a lockout an
// attacker can aim at a real user; a length floor makes guessing hopeless and
// only ever rejects a hand-picked token.
const MIN_AUTH_TOKEN_LENGTH = 32;
if (authToken && authToken.length < MIN_AUTH_TOKEN_LENGTH) {
exitWithRefusal(
`[gitnexus-web] Refusing to start: GITNEXUS_SERVE_AUTH_TOKEN is shorter than ` +
`${MIN_AUTH_TOKEN_LENGTH} characters. It is the only thing standing between the ` +
'public internet and every indexed repo, and a failed guess is not rate-limited. ' +
'Use a generated random value.',
);
}
// Whether an inbound X-Forwarded-For may be believed (see clientAddressFor).
// Default off, so a wrong deployment fails toward over-restriction rather than
// toward an address the caller picks. `true` is rejected as it is server-side
// (resolveTrustProxy, which also takes hop counts and so rejects `yes`/`on`
// too): it reads as "trust the whole chain".
function resolveTrustXff(raw) {
const value = raw?.trim();
if (!value) return false;
if (/^(1|yes|on)$/i.test(value)) return true;
if (/^(0|no|off|false)$/i.test(value)) return false;
console.warn(
`[gitnexus-web] GITNEXUS_PROXY_TRUST_XFF "${sanitizeForLog(value)}" is not a recognized ` +
'boolean -- ignoring the inbound X-Forwarded-For chain. Set 1 only when a load balancer ' +
'that appends the real peer sits in front of this service.',
);
return false;
}
const trustInboundXff = resolveTrustXff(process.env.GITNEXUS_PROXY_TRUST_XFF);
// Idle timeout for a proxied request → 504. Socket activity (SSE heartbeats)
// resets it, so long-lived streams are unaffected. 0 disables.
const proxyTimeoutMs = numberFromEnv('GITNEXUS_PROXY_TIMEOUT_MS', 120000);
// nginx's client_body_timeout equivalent: how long to wait for a replayable
// client body before 400. Defaults to the idle timeout; 0 disables.
const proxyClientBodyTimeoutMs = numberFromEnv(
'GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS',
proxyTimeoutMs,
);
// Bounded connection-retry, to ride out the few-second window where a
// single-instance upstream (private server + disk ⇒ no zero-downtime deploy)
// is restarting. Attempts of 1 disables it, and body buffering with it.
const proxyRetryAttempts = numberFromEnv('GITNEXUS_PROXY_RETRY_ATTEMPTS', 3, 1);
const proxyRetryEnabled = proxyRetryAttempts > 1;
const proxyRetryMaxBodyBytes = numberFromEnv('GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES', 256 * 1024);
// Never connected ⇒ the upstream got nothing ⇒ safe to replay any method.
const preConnectRetryCodes = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN']);
// Failed after connecting ⇒ the upstream may already be working on it, so
// replay only idempotent methods (RFC 7231 §4.2.2) to avoid double-execution.
const postConnectRetryCodes = new Set(['ECONNRESET', 'ETIMEDOUT']);
const idempotentMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE']);
// Buffer a request body, capped. Resolves null on overflow, client error, or
// timeout — one "unreadable body" contract, which the caller maps to 400.
// Listeners detach once settled so a later pipe of the same request is clean.
function readBodyCapped(req, cap, timeoutMs) {
return new Promise((resolvePromise) => {
const chunks = [];
let total = 0;
let settled = false;
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
req.removeListener('data', onData);
req.removeListener('end', onEnd);
req.removeListener('error', onError);
};
const finish = (value) => {
if (settled) return;
settled = true;
cleanup();
resolvePromise(value);
};
const onData = (chunk) => {
total += chunk.length;
if (total > cap) {
finish(null);
return;
}
chunks.push(chunk);
};
const onEnd = () => finish(Buffer.concat(chunks));
const onError = () => finish(null);
req.on('data', onData);
req.on('end', onEnd);
req.on('error', onError);
// Hard cap regardless of idle activity; Node's requestTimeout is the outer
// backstop.
if (timeoutMs > 0) {
timer = setTimeout(() => {
console.warn(`[gitnexus-web] client body read timed out after ${timeoutMs}ms`);
finish(null);
}, timeoutMs);
}
});
}
// Constant-time Bearer check, mirroring createAuthMiddleware in
// gitnexus/src/mcp/http-transport.ts — dummy comparison included, so an absent
// or wrong-length header costs the same and the timing can't leak the length.
// Duplicated because this file is plain ESM and can't import from gitnexus/src.
function authorized(req) {
if (!authToken) return true; // static-only: no proxy, nothing to gate
const header = req.headers['authorization'];
const expected = Buffer.from(`Bearer ${authToken}`);
if (typeof header !== 'string') {
timingSafeEqual(Buffer.alloc(expected.length), expected);
return false;
}
const provided = Buffer.from(header);
if (provided.length !== expected.length) {
timingSafeEqual(Buffer.alloc(expected.length), expected);
return false;
}
return timingSafeEqual(provided, expected);
}
// WWW-Authenticate names the scheme; the stable `code` is what the web client
// dispatches on, not message text. The body must not distinguish "no token
// configured" from "wrong token". `Connection: close` because we answer before
// reading the body, which Node would otherwise drain (as with the 400 below).
function sendUnauthorized(res) {
const body = JSON.stringify({ error: 'unauthorized', code: 'unauthorized' });
res.writeHead(401, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'WWW-Authenticate': 'Bearer',
Connection: 'close',
});
res.end(body);
}
// Fail a proxied request. Once headers are sent the body is partially written
// and can't be replaced, so the socket is all we can destroy.
function failGateway(res, status, message) {
if (res.headersSent) {
res.destroy();
} else {
res.writeHead(status, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(message);
}
}
// Hop-by-hop headers (RFC 7230 §6.1) describe one connection, so a proxy must
// not forward them in either direction; Node sets its own per hop.
const hopByHopHeaders = [
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
];
function stripHopByHopHeaders(headers) {
// §6.1 also lets `Connection` name additional single-hop headers, which the
// fixed list below can't cover. Node lowercases header keys on both the
// server and client side, so a lowercased name indexes `headers` directly.
for (const listed of String(headers.connection ?? '').split(',')) {
const name = listed.trim().toLowerCase();
if (name) delete headers[name];
}
for (const name of hopByHopHeaders) delete headers[name];
return headers;
}
// The client address this proxy vouches for upstream. The API keys its rate
// limiter off req.ip, so forwarding a client-supplied X-Forwarded-For would let
// anyone rotate a fake address per request. Which entry is real depends on a
// deployment fact this process can't observe (is anything in front appending the
// peer?), so the operator asserts it via GITNEXUS_PROXY_TRUST_XFF; until then we
// forward the socket peer.
function clientAddressFor(req) {
if (!trustInboundXff) return req.socket.remoteAddress || null;
const forwarded = String(req.headers['x-forwarded-for'] ?? '')
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.pop();
return forwarded || req.socket.remoteAddress || null;
}
// Forward an `/api/*` request upstream, streaming both bodies (SSE / chunked
// graph streams) untouched. Retries connect failures when the body is replayable.
async function proxyToUpstream(req, res) {
let upstream;
try {
upstream = new URL(req.url, upstreamBase);
} catch {
res.writeHead(400);
res.end('Bad request');
return;
}
// The `/api/` route guard keeps req.url host-relative, so resolution can't
// leave upstreamBase. Asserting it here means the SSRF boundary doesn't rest
// on that two-step argument: one legitimate destination, checked locally.
if (upstream.origin !== upstreamOrigin) {
console.error(`[gitnexus-web] refusing to proxy off-origin target ${upstream.origin}`);
res.writeHead(400);
res.end('Bad request');
return;
}
const isHttps = upstream.protocol === 'https:';
const requestFn = isHttps ? httpsRequest : httpRequest;
const headers = stripHopByHopHeaders({ ...req.headers });
// Terminate the browser origin: the API admits Origin-less requests as
// trusted server-to-server calls. Nothing is lost — the browser only ever
// talks to this same-origin web service.
delete headers.origin;
delete headers.referer;
// The edge token is spent here. `serve` reads no Authorization header
// (gitnexus/src/server/mcp-http.ts mounts /api/mcp unguarded), so forwarding
// it would only copy a live credential into another service's logs. Pinned by
// test.
delete headers.authorization;
headers.host = upstream.host;
// Replace, never forward, the inbound chain (see clientAddressFor).
const clientAddress = clientAddressFor(req);
if (clientAddress) headers['x-forwarded-for'] = clientAddress;
else delete headers['x-forwarded-for'];
// A retry replays the body, so buffer it up front — but only when small and
// of known length. Larger/unknown bodies (multipart uploads) stream once with
// no retry; an upload is never buffered.
const method = (req.method || 'GET').toUpperCase();
const isIdempotentMethod = idempotentMethods.has(method);
// A request has a body iff it frames one (RFC 7230 §3.3.3). Keying off the
// method sends a bodyless DELETE down the stream-once path and gives up a
// replay that costs nothing.
const hasBody =
req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined;
const len = Number(req.headers['content-length']);
const bufferable =
proxyRetryEnabled && Number.isFinite(len) && len >= 0 && len <= proxyRetryMaxBodyBytes;
let bodyBuf = hasBody ? null : Buffer.alloc(0);
if (hasBody && bufferable) {
bodyBuf = await readBodyCapped(req, proxyRetryMaxBodyBytes, proxyClientBodyTimeoutMs);
if (bodyBuf === null) {
// Overflow, client error, and timeout all collapse to 400 (not 413/408).
// `Connection: close` lets Node drop the socket after the 400 flushes,
// rather than half-open draining a stalled upload until requestTimeout.
if (!res.headersSent) {
res.writeHead(400, {
'Content-Type': 'text/plain; charset=utf-8',
Connection: 'close',
});
res.end('Bad request');
}
return;
}
}
// bodyBuf === null means "stream the live request once, no retry".
const retryEligible = bodyBuf !== null;
const attempt = (n) => {
let timedOut = false;
const upstreamReq = requestFn(
{
protocol: upstream.protocol,
hostname: upstream.hostname,
port: upstream.port || (isHttps ? 443 : 80),
method: req.method,
path: upstream.pathname + upstream.search,
headers,
},
(upstreamRes) => {
// Pipe rather than buffer, so SSE / chunked streams reach the browser
// incrementally. Node re-derives Transfer-Encoding for this hop.
const responseHeaders = stripHopByHopHeaders({ ...upstreamRes.headers });
res.writeHead(upstreamRes.statusCode || 502, responseHeaders);
upstreamRes.on('error', () => res.destroy());
upstreamRes.pipe(res);
},
);
upstreamReq.on('error', (err) => {
if (timedOut) return; // 504 already sent by the timeout handler below
// Only before any response byte reaches the browser — once headers are
// sent the body is partially written and can't be replayed.
const retryableError =
preConnectRetryCodes.has(err.code) ||
(isIdempotentMethod && postConnectRetryCodes.has(err.code));
if (retryEligible && !res.headersSent && n < proxyRetryAttempts && retryableError) {
const delay = 250 * 2 ** (n - 1); // 250ms, 500ms, ...
console.warn(
`[gitnexus-web] upstream ${sanitizeForLog(err.code)}; retry ${n}/${proxyRetryAttempts - 1} in ${delay}ms`,
);
setTimeout(() => {
// The client may have aborted during the backoff window; don't fire a
// fresh upstream request nobody is waiting for anymore.
if (res.writableEnded || res.destroyed) return;
attempt(n + 1);
}, delay);
return;
}
console.error('[gitnexus-web] upstream proxy error:', sanitizeForLog(err.message));
failGateway(res, 502, 'Bad gateway');
});
if (proxyTimeoutMs > 0) {
upstreamReq.setTimeout(proxyTimeoutMs, () => {
timedOut = true;
console.error(`[gitnexus-web] upstream proxy timeout after ${proxyTimeoutMs}ms`);
failGateway(res, 504, 'Gateway timeout');
upstreamReq.destroy();
});
}
if (bodyBuf !== null) {
// Replayable body already buffered; write it fresh on each attempt.
if (bodyBuf.length) upstreamReq.write(bodyBuf);
upstreamReq.end();
} else {
// Non-retryable: stream the live request once.
req.on('error', () => upstreamReq.destroy());
req.pipe(upstreamReq);
}
};
attempt(1);
}
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
@ -68,6 +483,23 @@ const spaFallback = resolve(root, 'index.html');
const server = createServer(async (req, res) => {
const urlPath = req.url?.split('?')[0] || '/';
// Same-origin API proxy; everything else falls through to the SPA below.
if (upstreamBase && (urlPath === '/api' || urlPath.startsWith('/api/'))) {
// Before body buffering and the upstream socket, so an unauthenticated
// request costs nothing upstream. Static assets are never gated: the UI has
// to load in order to prompt for the token.
if (!authorized(req)) {
sendUnauthorized(res);
return;
}
// Fire-and-forget, so guard the boundary against unhandledRejection.
proxyToUpstream(req, res).catch((err) => {
console.error('[gitnexus-web] proxy handler crashed:', sanitizeForLog(err?.message ?? err));
failGateway(res, 502, 'Bad gateway');
});
return;
}
let decoded;
try {
decoded = decodeURIComponent(urlPath);

View file

@ -1,4 +1,5 @@
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import { connect } from 'node:net';
import http, { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@ -263,3 +264,825 @@ it('does not inject config into static assets', async () => {
assert.equal(res.body, 'body{}');
});
});
// -- API reverse proxy (GITNEXUS_UPSTREAM_URL) -----------------------------
// Every proxy fixture below runs the server with this token: the proxy refuses
// to start without one, and refuses one under 32 characters.
const TEST_AUTH_TOKEN = 'proxy-test-token-0123456789abcdefghij';
const TEST_BEARER = `Bearer ${TEST_AUTH_TOKEN}`;
// rawRequest never sends credentials; apiRequest does. In a file whose subject
// is who gets let through, no test should pass because a helper quietly
// authenticated for it.
function rawRequest(port, path, { method = 'GET', headers = {}, body } = {}) {
// Send an explicit Content-Length like a browser fetch() does — the proxy
// only buffers (and so only retries) bodies of known length.
const outHeaders = { ...headers };
if (
body !== undefined &&
!Object.keys(outHeaders).some((h) => h.toLowerCase() === 'content-length')
) {
outHeaders['content-length'] = String(Buffer.byteLength(body));
}
return new Promise((resolve, reject) => {
const req = http.request(
{ host: '127.0.0.1', port, path, method, headers: outHeaders },
(res) => {
let respBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
respBody += chunk;
});
res.on('end', () =>
resolve({ status: res.statusCode, headers: res.headers, body: respBody }),
);
},
);
req.on('error', reject);
if (body !== undefined) req.write(body);
req.end();
});
}
// An authenticated /api/* call. An explicit `authorization` header wins, so the
// auth tests can send a wrong one.
function apiRequest(port, path, { headers = {}, ...rest } = {}) {
const hasAuth = Object.keys(headers).some((h) => h.toLowerCase() === 'authorization');
return rawRequest(port, path, {
...rest,
headers: hasAuth ? headers : { ...headers, authorization: TEST_BEARER },
});
}
const respondOk = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end('{"ok":true}');
};
// Every proxy test needs the same four parts: a dist/ to serve, a fake upstream,
// a docker-server pointed at it, and teardown that leaks neither a process nor a
// temp dir. They differ only in how the upstream misbehaves.
//
// upstream request handler, replaceable mid-test via `ctx.handler`;
// null points the proxy at a port nothing ever listens on
// listenAfterMs bind the upstream this late, so the first attempt(s) hit
// ECONNREFUSED (a single-instance restart window)
// schemeless drop http:// from GITNEXUS_UPSTREAM_URL, the way Render's
// `fromService: { property: hostport }` yields it
// env extra environment for docker-server.mjs
//
// `ctx` collects what the upstream saw (calls, last request, last body) plus the
// proxy's stderr, so assertions read off one object.
async function withProxy(
{ upstream = respondOk, listenAfterMs = 0, schemeless = false, env = {} } = {},
fn,
) {
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-proxy-'));
await mkdir(join(dir, 'dist'), { recursive: true });
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
const ctx = { calls: 0, received: null, body: null, stderr: '', handler: upstream };
// Read the forwarded request to completion before handing it to the handler,
// so no test has to repeat that plumbing to assert on headers or body.
const server = upstream
? createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
ctx.calls += 1;
ctx.body = body;
ctx.received = { method: req.method, url: req.url, headers: req.headers, body };
ctx.handler(req, res);
});
})
: null;
// A late (or never) bind needs its port reserved up front; otherwise let the
// OS assign one at listen time.
const upstreamPort =
server && listenAfterMs === 0
? await new Promise((r) => server.listen(0, '127.0.0.1', () => r(server.address().port)))
: await getFreePort();
const bindTimer =
server && listenAfterMs > 0
? setTimeout(() => server.listen(upstreamPort, '127.0.0.1'), listenAfterMs)
: null;
const port = await getFreePort();
const target = `127.0.0.1:${upstreamPort}`;
const proc = spawnServerWithEnv(dir, port, {
GITNEXUS_UPSTREAM_URL: schemeless ? target : `http://${target}`,
GITNEXUS_SERVE_AUTH_TOKEN: TEST_AUTH_TOKEN,
...env,
});
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
ctx.stderr += chunk;
});
try {
await waitForServer(port);
await fn(port, ctx);
} finally {
if (bindTimer) clearTimeout(bindTimer);
await killAndWait(proc);
if (server?.listening) {
server.closeAllConnections?.();
await new Promise((r) => server.close(r));
}
await rm(dir, { recursive: true, force: true });
}
}
it('proxies /api/* requests to the upstream server', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/info?x=1');
assert.equal(res.status, 200);
assert.match(res.body, /"ok":true/);
assert.equal(ctx.received.url, '/api/info?x=1', 'path + query forwarded verbatim');
});
});
it('forwards the request method and body to the upstream', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"q":"hello"}',
});
assert.equal(ctx.received.method, 'POST');
assert.equal(ctx.received.body, '{"q":"hello"}');
});
});
it('strips the browser Origin and Referer before forwarding to the API', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { origin: 'https://gitnexus-web.onrender.com', referer: 'https://x/y' },
});
assert.equal(
ctx.received.headers.origin,
undefined,
'Origin must be stripped so the API treats it as a trusted server-to-server call',
);
assert.equal(ctx.received.headers.referer, undefined, 'Referer must be stripped');
});
});
it('strips hop-by-hop headers before forwarding to the API', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: {
'keep-alive': 'timeout=5',
upgrade: 'h2c',
'proxy-authorization': 'Basic abc',
te: 'trailers',
},
});
assert.equal(ctx.received.headers['keep-alive'], undefined);
assert.equal(ctx.received.headers.upgrade, undefined);
assert.equal(ctx.received.headers['proxy-authorization'], undefined);
assert.equal(ctx.received.headers.te, undefined);
});
});
it('strips request headers that Connection names as single-hop', async () => {
await withProxy({}, async (port, ctx) => {
// RFC 7230 §6.1 lets Connection name hop-by-hop headers beyond the
// well-known eight, and those must not be forwarded either. Against a fixed
// list alone, x-custom-hop reaches the upstream.
await apiRequest(port, '/api/info', {
headers: { connection: 'x-custom-hop', 'x-custom-hop': 'private' },
});
assert.equal(ctx.received.headers['x-custom-hop'], undefined);
// Connection itself is always re-derived by Node for the upstream hop, so
// assert the client's value didn't survive rather than that it's absent.
assert.notEqual(ctx.received.headers.connection, 'x-custom-hop');
});
});
it('collapses a spoofed X-Forwarded-For chain to the load balancer entry when XFF is trusted', async () => {
const env = { GITNEXUS_PROXY_TRUST_XFF: '1' };
await withProxy({ env }, async (port, ctx) => {
// With a load balancer in front, only the last entry is the LB's; the rest
// is client-supplied and would otherwise let a caller fake req.ip and evade
// the API's rate limits.
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
});
assert.equal(ctx.received.headers['x-forwarded-for'], '203.0.113.9');
});
});
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is unset', async () => {
await withProxy({}, async (port, ctx) => {
// With nothing in front of the proxy, the whole chain is the caller's to
// write, so popping it would forward an address they chose.
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '10.0.0.1, 1.2.3.4, 203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
});
});
it('ignores an inbound X-Forwarded-For chain when GITNEXUS_PROXY_TRUST_XFF is off', async () => {
const env = { GITNEXUS_PROXY_TRUST_XFF: 'off' };
await withProxy({ env }, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
});
});
it('warns and falls back to ignoring XFF when GITNEXUS_PROXY_TRUST_XFF is "true"', async () => {
// Rejected for the same reason resolveTrustProxy rejects it server-side: it
// reads as "trust everything", the configuration this knob exists to make
// deliberate.
const env = { GITNEXUS_PROXY_TRUST_XFF: 'true' };
await withProxy({ env }, async (port, ctx) => {
await apiRequest(port, '/api/info', {
headers: { 'x-forwarded-for': '203.0.113.9' },
});
assert.match(ctx.received.headers['x-forwarded-for'], /127\.0\.0\.1$/);
assert.match(
ctx.stderr,
/GITNEXUS_PROXY_TRUST_XFF "true" is not a recognized boolean/,
'an unrecognized value must warn rather than fail silently',
);
});
});
it('forwards the socket peer, not the rotating header, on every authenticated request', async () => {
// A caller rotating X-Forwarded-For per request earns a fresh limiter key
// upstream unless this proxy overwrites it. Hitting the API server directly
// would test its own trust-proxy handling instead of this hop.
await withProxy({}, async (port, ctx) => {
const forwarded = [];
for (const spoofed of ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4']) {
await apiRequest(port, '/api/query', {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-forwarded-for': spoofed },
body: '{"q":"hi"}',
});
forwarded.push(ctx.received.headers['x-forwarded-for']);
}
assert.equal(ctx.calls, 4);
for (const address of forwarded) {
assert.match(
address,
/127\.0\.0\.1$/,
'every request must key off the socket peer, not the value the client rotated',
);
}
});
});
it('sets X-Forwarded-For from the socket peer when the client sends none', async () => {
await withProxy({}, async (port, ctx) => {
await apiRequest(port, '/api/info');
assert.match(
ctx.received.headers['x-forwarded-for'],
/127\.0\.0\.1$/,
'the API must always see a proxy-derived client address',
);
});
});
it('strips hop-by-hop headers from the upstream response', async () => {
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain', Trailer: 'X-Late' });
res.end('ok');
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(res.headers.trailer, undefined, 'Trailer describes the upstream hop only');
assert.equal(res.body, 'ok');
});
});
it('strips response headers that Connection names as single-hop', async () => {
const upstream = (_req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain',
Connection: 'x-upstream-hop',
'x-upstream-hop': 'internal',
});
res.end('ok');
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(res.headers['x-upstream-hop'], undefined, 'named on the upstream hop only');
});
});
it('does NOT proxy non-/api routes (still serves the SPA)', async () => {
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/some/app/route');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
assert.equal(ctx.calls, 0, 'non-/api requests must not reach the upstream');
});
});
it('streams a chunked upstream response through to the client', async () => {
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write('data: one\n\n');
setTimeout(() => {
res.write('data: two\n\n');
res.end();
}, 20);
};
await withProxy({ upstream }, async (port) => {
const res = await apiRequest(port, '/api/stream');
assert.equal(res.status, 200);
assert.equal(res.headers['content-type'], 'text/event-stream');
assert.match(res.body, /data: one/);
assert.match(res.body, /data: two/);
});
});
it('accepts a scheme-less host:port upstream (Render fromService hostport)', async () => {
await withProxy({ schemeless: true }, async (port, ctx) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(ctx.received.url, '/api/info', 'scheme-less upstream should still be proxied');
});
});
it('serves RENDER_EXTERNAL_URL as the backend origin when GITNEXUS_BACKEND_URL is unset', async () => {
await withInjectionServer(
{ RENDER_EXTERNAL_URL: 'https://gitnexus-web.onrender.com' },
async (port) => {
const res = await rawGet(port, '/');
assert.equal(res.status, 200);
// Assert on the parsed value, not a substring of the page: a bare
// includes() would also pass if the URL appeared in a comment.
const injected = /window\.__GITNEXUS_CONFIG__=(\{.*?\});/.exec(res.body)?.[1];
assert.ok(injected, 'Expected __GITNEXUS_CONFIG__ in response body');
assert.equal(JSON.parse(injected).backendUrl, 'https://gitnexus-web.onrender.com');
},
);
});
it('returns 504 when the upstream does not respond within the timeout', async () => {
// Upstream accepts the connection but never responds — an idle hang.
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '300' };
await withProxy({ upstream: () => {}, env }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 504);
});
});
it('returns 502 when the upstream is unreachable', async () => {
// Retry disabled so this fails fast (the unreachable-upstream contract).
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '1' };
await withProxy({ upstream: null, env }, async (port) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 502);
});
});
// -- Connection-retry across an upstream restart window ---------------------
//
// `listenAfterMs: 400` binds the upstream late, so the first attempt hits
// ECONNREFUSED and must be retried — a single-instance restart. The default 3
// attempts (backoff 250ms, 500ms) span ~750ms, so a retry lands after the bind.
it('retries a connection-refused POST and succeeds once the upstream is up', async () => {
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 200, 'first attempt should ride out the restart gap');
assert.match(res.body, /"ok":true/);
assert.equal(ctx.calls, 1, 'upstream must run the job exactly once (no double-execute)');
assert.equal(ctx.body, '{"repo":"x"}', 'buffered body replayed intact');
});
});
it('retries a bodyless DELETE, which frames no body to replay', async () => {
// Retry eligibility follows RFC 7230 §3.3.3 framing. A DELETE with neither
// Content-Length nor Transfer-Encoding has nothing to buffer, so it replays
// safely even though it isn't a GET.
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
const res = await apiRequest(port, '/api/repo', { method: 'DELETE' });
assert.equal(res.status, 200, 'a bodyless DELETE must ride out the restart gap');
assert.equal(ctx.calls, 1);
});
});
it('falls back to the default retry budget when the knob is out of range', async () => {
// A negative attempt count is a typo. Obeying it would turn every restart
// window into a 502, silently.
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '-1' };
await withProxy({ listenAfterMs: 400, env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/info');
assert.equal(res.status, 200);
assert.equal(ctx.calls, 1);
});
});
it('warns and keeps the default when a timeout knob is negative', async () => {
const env = { GITNEXUS_PROXY_TIMEOUT_MS: '-1' };
await withProxy({ upstream: null, env }, async (_port, ctx) => {
// Every consumer reads <= 0 as "disabled", so an unvalidated -1 removes the
// idle timeout and lets a proxied request hang forever.
assert.match(
ctx.stderr,
/GITNEXUS_PROXY_TIMEOUT_MS "-1" is below the minimum 0 -- using 120000/,
);
});
});
it('does NOT retry after the client aborts during the backoff window', async () => {
// The client aborts (~100ms) while a retry is pending, before the upstream
// binds (~400ms). The backoff guard must cancel it — otherwise the retry
// lands after the bind and runs a job nobody is waiting on.
await withProxy({ listenAfterMs: 400 }, async (port, ctx) => {
await new Promise((resolve) => {
const req = http.request({
host: '127.0.0.1',
port,
path: '/api/analyze',
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': '12',
authorization: TEST_BEARER,
},
});
req.on('error', () => {}); // aborting surfaces a local socket error; ignore
req.write('{"repo":"x"}');
req.end();
// Abort after the first attempt has failed-and-scheduled (ECONNREFUSED is
// near-instant) but well before the upstream binds at ~400ms.
setTimeout(() => {
req.destroy();
resolve();
}, 100);
});
// Wait past the upstream bind + full retry budget (~750ms) so a leaked retry
// would already have landed.
await new Promise((r) => setTimeout(r, 900));
assert.equal(ctx.calls, 0, 'aborted request must not be retried against the upstream');
});
});
it('returns 502 after exhausting the retry budget when the upstream stays down', async () => {
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream: null, env }, async (port) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 502, 'genuinely-down upstream still returns 502 after the budget');
});
});
it('does NOT retry a POST that connects then resets before responding', async () => {
// The upstream accepts the connection, reads the whole request, then dies
// before sending any response byte — an instance that received the job and
// crashed/restarted mid-flight. Because the reset arrives AFTER connecting and
// POST is non-idempotent, replaying could run the job twice, so the proxy must
// NOT retry: the upstream sees exactly one call and the browser gets 502.
const upstream = (_req, res) => res.socket.destroy();
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream, env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: '{"repo":"x"}',
});
assert.equal(res.status, 502, 'post-connection reset on a POST fails fast, no retry');
// Give any (erroneous) retry a chance to fire before asserting.
await new Promise((r) => setTimeout(r, 300));
assert.equal(
ctx.calls,
1,
'non-idempotent POST must not be replayed after the upstream got it',
);
});
});
it('does NOT retry after the upstream starts streaming, then drops mid-body', async () => {
// Send headers + a partial body, then abruptly destroy the socket.
const upstream = (_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write('{"partial":');
setTimeout(() => res.socket.destroy(), 20);
};
const env = { GITNEXUS_PROXY_RETRY_ATTEMPTS: '3' };
await withProxy({ upstream, env }, async (port, ctx) => {
// Settle on end OR on the mid-body abort/error, so the dropped connection
// can't hang the test. What matters is that the proxy did NOT replay the
// request (no duplicate job): the upstream must see exactly 1 call.
await new Promise((resolve) => {
const req = http.request(
{
host: '127.0.0.1',
port,
path: '/api/analyze',
method: 'POST',
headers: {
'content-type': 'application/json',
'content-length': '12',
authorization: TEST_BEARER,
},
},
(res) => {
res.on('data', () => {});
res.on('end', resolve);
res.on('aborted', resolve);
res.on('error', resolve);
},
);
req.on('error', resolve);
req.write('{"repo":"x"}');
req.end();
});
// Give any (erroneous) retry a chance to fire before asserting.
await new Promise((r) => setTimeout(r, 300));
assert.equal(ctx.calls, 1, 'must not replay once the response body has started');
});
});
it('does NOT buffer or retry a body larger than the retry cap', async () => {
// Tiny cap so a modest body exceeds it and is streamed, not buffered.
const env = { GITNEXUS_PROXY_RETRY_MAX_BODY_BYTES: '16' };
const bigBody = 'x'.repeat(1024);
await withProxy({ env }, async (port, ctx) => {
const res = await apiRequest(port, '/api/analyze/upload', {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body: bigBody,
});
assert.equal(res.status, 200, 'over-cap body is streamed straight through');
assert.equal(ctx.body.length, bigBody.length, 'full body reaches upstream (not capped)');
});
});
it('returns 400 when the client declares a body but never finishes sending it', async () => {
// A live upstream, so a failure to reach it can't be mistaken for the body
// timeout. It must see zero requests: the proxy never connects because the
// buffering read times out first. The dedicated knob is set (leaving the
// upstream idle timeout at its default) to prove the two tune independently.
const env = { GITNEXUS_PROXY_CLIENT_BODY_TIMEOUT_MS: '300' };
await withProxy({ env }, async (port, ctx) => {
// Raw socket (not http.request, which would auto-finish the body): send a
// Content-Length: 100 request but only 10 bytes, then hold the socket open.
// We never close our side — the proxy must close it for us once the body
// read times out (via `Connection: close`), rather than holding the
// half-open connection until the server requestTimeout reaps it.
const { status, serverClosed, raw } = await new Promise((resolve) => {
const sock = connect(port, '127.0.0.1', () => {
sock.write(
'POST /api/analyze HTTP/1.1\r\n' +
'Host: 127.0.0.1\r\n' +
'Content-Type: application/json\r\n' +
`Authorization: ${TEST_BEARER}\r\n` +
'Content-Length: 100\r\n' +
'\r\n' +
'x'.repeat(10), // fewer than 100 bytes, then stall
);
});
let buf = '';
let status = null;
// Fail-safe: if the proxy never closes on its own, report serverClosed
// false (so the assertion fails cleanly) instead of hanging the test.
const guard = setTimeout(() => {
sock.destroy();
resolve({ status, serverClosed: false, raw: buf });
}, 2000);
sock.setEncoding('utf8');
sock.on('data', (chunk) => {
buf += chunk;
if (status === null) {
const m = buf.split('\r\n', 1)[0].match(/^HTTP\/\d\.\d (\d{3})/);
if (m) status = Number(m[1]);
}
});
// The server closing its side (Connection: close) ends our socket; treat
// any teardown initiated by the server as "closed promptly".
sock.on('error', () => {}); // a reset may precede 'close'; swallow it
sock.on('close', () => {
clearTimeout(guard);
resolve({ status, serverClosed: true, raw: buf });
});
});
assert.equal(status, 400, 'stalled body read must be bounded and return 400, not hang');
assert.ok(
serverClosed,
'proxy must close the half-open connection promptly, not hold it until requestTimeout',
);
assert.match(
raw.toLowerCase(),
/connection: close/,
'the 400 for a stalled body must advertise Connection: close',
);
assert.equal(ctx.calls, 0, 'proxy must not connect upstream when the body never arrives');
});
});
// -- Token gate at the public edge (GITNEXUS_SERVE_AUTH_TOKEN) --------------
//
// The proxy terminates the browser Origin, so the API's own write guard can't
// see a cross-site request coming. The token replaces it, checked on the way in.
it('answers an /api/* request with no Authorization header with a well-formed 401', async () => {
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/api/health');
assert.equal(res.status, 401);
assert.equal(res.headers['www-authenticate'], 'Bearer');
assert.match(res.headers['content-type'], /application\/json/);
// The UI dispatches on the stable code, not on message text.
assert.deepEqual(JSON.parse(res.body), { error: 'unauthorized', code: 'unauthorized' });
assert.equal(ctx.calls, 0, 'an unauthenticated request must cost nothing upstream');
});
});
it('closes the connection on a rejected request rather than draining its body', async () => {
// The 401 is answered before the body is read, so without Connection: close
// Node drains up to 64KB of an unauthenticated upload to keep the socket
// reusable. Same reasoning as the stalled-body 400 above.
await withProxy({}, async (port, ctx) => {
const res = await rawRequest(port, '/api/analyze', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ path: '/etc' }),
});
assert.equal(res.status, 401);
assert.equal(res.headers.connection, 'close');
assert.equal(ctx.calls, 0);
});
});
it('rejects a wrong token of the same length', async () => {
await withProxy({}, async (port, ctx) => {
const wrong = 'x'.repeat(TEST_AUTH_TOKEN.length);
const res = await apiRequest(port, '/api/health', {
headers: { authorization: `Bearer ${wrong}` },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('rejects a wrong token of a different length', async () => {
// The unequal-length branch takes a different path through the comparison
// (dummy compare, no timingSafeEqual on the real buffers) and still must 401.
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: 'Bearer short' },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('rejects the raw token without the Bearer prefix', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: TEST_AUTH_TOKEN },
});
assert.equal(res.status, 401);
assert.equal(ctx.calls, 0);
});
});
it('forwards an /api/* request that carries the correct token', async () => {
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/health', {
headers: { authorization: TEST_BEARER },
});
assert.equal(res.status, 200);
assert.equal(ctx.calls, 1);
});
});
it('strips the Authorization header instead of forwarding the edge token', async () => {
// The token is spent at this hop. `serve` reads no Authorization header, so
// forwarding would only copy a live credential into another service's logs.
await withProxy({}, async (port, ctx) => {
const res = await apiRequest(port, '/api/mcp', { method: 'POST', body: '{}' });
assert.equal(res.status, 200, 'the request itself must still be proxied');
assert.equal(ctx.received.headers.authorization, undefined);
});
});
it('never gates static assets behind the token', async () => {
// The UI has to load before it can prompt for a token.
await withProxy({}, async (port, ctx) => {
for (const path of ['/', '/index.html', '/some/app/route']) {
const res = await rawRequest(port, path);
assert.equal(res.status, 200, `${path} must be served without a token`);
assert.match(res.body, /spa/);
}
assert.equal(ctx.calls, 0);
});
});
// Run docker-server.mjs to completion and report how it exited. Used for the
// boot-time refusal, which never reaches a listening state.
function runUntilExit(cwd, env) {
return new Promise((resolve, reject) => {
const proc = spawn(process.execPath, [serverScript], {
cwd,
env: { ...process.env, ...env },
stdio: 'pipe',
});
let stderr = '';
proc.stderr.setEncoding('utf8');
proc.stderr.on('data', (chunk) => {
stderr += chunk;
});
proc.on('error', reject);
proc.on('exit', (code) => resolve({ code, stderr }));
// A server that starts instead of refusing never exits, so name that failure
// here rather than letting it surface as a timeout or a null exit code.
setTimeout(() => {
proc.kill();
reject(new Error('docker-server.mjs kept running; it was expected to refuse and exit'));
}, 5000).unref();
});
}
async function withDistDir(fn) {
const dir = await mkdtemp(join(tmpdir(), 'gitnexus-boot-'));
await mkdir(join(dir, 'dist'), { recursive: true });
await writeFile(join(dir, 'dist', 'index.html'), '<html><body>spa</body></html>');
try {
await fn(dir);
} finally {
await rm(dir, { recursive: true, force: true });
}
}
it('refuses to start when the proxy is enabled without a token', async () => {
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: undefined,
});
assert.equal(code, 1, 'an unauthenticated public proxy must fail closed at boot');
assert.match(stderr, /Refusing to start/);
assert.match(stderr, /GITNEXUS_SERVE_AUTH_TOKEN/);
});
});
it('refuses to start when the token is short enough to guess', async () => {
// Nothing rate-limits a failed token, so a weak one is guessable at network
// speed. The floor is what makes the missing limiter safe.
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: 'hunter2',
});
assert.equal(code, 1);
assert.match(stderr, /shorter than 32 characters/);
assert.ok(!stderr.includes('hunter2'), 'the refusal must never echo the token');
});
});
it('treats a whitespace-only token as absent rather than as a short one', async () => {
// ' ' trims to empty, so this must hit the missing-token refusal, not the
// length one.
await withDistDir(async (dir) => {
const port = await getFreePort();
const { code, stderr } = await runUntilExit(dir, {
PORT: String(port),
GITNEXUS_UPSTREAM_URL: '127.0.0.1:4747',
GITNEXUS_SERVE_AUTH_TOKEN: ' ',
});
assert.equal(code, 1);
assert.match(stderr, /is set without GITNEXUS_SERVE_AUTH_TOKEN/);
});
});
it('starts normally with neither the proxy nor a token configured', async () => {
// docker-compose's default: static assets only, nothing to gate, no refusal.
await withDistDir(async (dir) => {
const port = await getFreePort();
const proc = spawnServerWithEnv(dir, port, { GITNEXUS_SERVE_AUTH_TOKEN: undefined });
try {
await waitForServer(port);
const res = await rawRequest(port, '/');
assert.equal(res.status, 200);
assert.match(res.body, /spa/);
} finally {
await killAndWait(proc);
}
});
});

View file

@ -0,0 +1,67 @@
import { useState } from 'react';
import { Key } from '@/lib/lucide-icons';
import { useTranslation } from 'react-i18next';
import { getAuthToken, setAuthToken } from '../services/backend-client';
import { SecretInput } from './settings/SecretInput';
interface AccessTokenPromptProps {
/** Called after the token is stored, so the caller can re-probe immediately. */
onSubmit?: () => void;
}
/**
* Shown instead of the "start a server" guide when the backend answers 401:
* the deploy is up, it just needs the access token its operator generated.
*
* The token is held in sessionStorage for this browser session only see
* AUTH_TOKEN_STORAGE_KEY. Nothing here logs it or puts it in a URL.
*/
export const AccessTokenPrompt = ({ onSubmit }: AccessTokenPromptProps) => {
const { t } = useTranslation('settings');
const [token, setToken] = useState(getAuthToken);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setAuthToken(token);
onSubmit?.();
};
return (
<form
onSubmit={handleSubmit}
className="animate-fade-in rounded-3xl border border-border-default bg-surface p-7"
>
<div className="mb-5 text-center">
<div className="mb-3 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-accent/20">
<Key className="h-5 w-5 text-accent" />
</div>
<h2 className="text-lg leading-snug font-semibold text-text-primary">
{t('accessToken.title')}
</h2>
<p className="mx-auto mt-1.5 max-w-sm text-sm leading-relaxed text-text-secondary">
{t('accessToken.promptHint')}
</p>
</div>
<SecretInput
value={token}
onChange={setToken}
label={t('accessToken.label')}
placeholder={t('accessToken.placeholder')}
revealLabel={t('accessToken.reveal')}
hideLabel={t('accessToken.hide')}
/>
<button
type="submit"
className="mt-4 w-full cursor-pointer rounded-xl bg-accent px-4 py-3 text-sm font-medium text-white shadow-glow-soft transition-all hover:bg-accent/90 hover:shadow-glow"
>
{t('accessToken.connect')}
</button>
<p className="mt-4 border-t border-border-subtle pt-4 text-center text-xs leading-relaxed text-text-muted">
{t('accessToken.sessionNote')}
</p>
</form>
);
};

View file

@ -7,6 +7,7 @@ import {
type BackendRepo,
} from '../services/backend-client';
import { useBackend } from '../hooks/useBackend';
import { AccessTokenPrompt } from './AccessTokenPrompt';
import { OnboardingGuide } from './OnboardingGuide';
import { AnalyzeOnboarding } from './AnalyzeOnboarding';
import { RepoLanding } from './RepoLanding';
@ -147,6 +148,7 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
const {
isConnected,
isProbing,
isUnauthorized,
startPolling,
stopPolling,
isPolling,
@ -310,8 +312,20 @@ export const DropZone = ({ onServerConnect }: DropZoneProps) => {
</div>
)}
{/* The backend is up but gated asking for a token is the only useful
thing to show. The "run gitnexus serve" guide would be wrong advice. */}
{isUnauthorized && !isConnected && (
<AccessTokenPrompt
onSubmit={() => {
// The polling chain is already running while disconnected; it
// picks up the new token on its next tick and auto-connects.
if (!isPolling) startPolling();
}}
/>
)}
{/* Crossfade between phases */}
{displayPhase && (
{!isUnauthorized && displayPhase && (
<Crossfade activeKey={displayPhase}>
{displayPhase === 'onboarding' && <OnboardingGuide isPolling={isPolling} />}
{displayPhase === 'analyze' && <AnalyzeOnboarding onComplete={connectToRepo} />}

View file

@ -20,9 +20,11 @@ import {
getAvailableModels,
fetchOpenRouterModels,
} from '../core/llm/settings-service';
import { getAuthToken, setAuthToken } from '../services/backend-client';
import type { LLMSettings, LLMProvider } from '../core/llm/types';
import { DEFAULT_OLLAMA_BASE_URL } from '../config/ui-constants';
import { ProviderConfigCard } from './settings/ProviderConfigCard';
import { SecretInput } from './settings/SecretInput';
import { useTranslation } from 'react-i18next';
interface SettingsPanelProps {
@ -253,6 +255,8 @@ export const SettingsPanel = ({
const { t } = useTranslation(['common', 'settings']);
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
/** Deploy access token. Stored outside LLM settings, persisted on Save. */
const [authToken, setAuthTokenState] = useState(getAuthToken);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Ollama connection state
@ -275,6 +279,7 @@ export const SettingsPanel = ({
useEffect(() => {
if (isOpen) {
setSettings(loadSettings());
setAuthTokenState(getAuthToken());
setSaveStatus('idle');
setOllamaError(null);
}
@ -315,6 +320,10 @@ export const SettingsPanel = ({
const handleSave = () => {
try {
saveSettings(settings);
// The token persists on Save with everything else, not per keystroke: it
// is the only affordance this panel gives for "committed", and a
// half-typed token would otherwise ride the next probe.
setAuthToken(authToken);
setSaveStatus('saved');
onSettingsSaved?.();
if (saveTimerRef.current) {
@ -372,6 +381,25 @@ export const SettingsPanel = ({
{/* Content */}
<div className="flex-1 space-y-6 overflow-y-auto p-6">
{/* Deploy access token. Rendered unconditionally, unlike the Local
Server block below, which only appears when a caller passes the
backend-URL props. An empty token is a valid state a local
`gitnexus serve` or `docker compose` deploy has no gate. */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
{t('settings:accessToken.label')}
</label>
<SecretInput
value={authToken}
onChange={setAuthTokenState}
label={t('settings:accessToken.label')}
placeholder={t('settings:accessToken.placeholder')}
revealLabel={t('settings:accessToken.reveal')}
hideLabel={t('settings:accessToken.hide')}
/>
<p className="text-xs text-text-muted">{t('settings:accessToken.hint')}</p>
</div>
{/* Local Server */}
{backendUrl !== undefined && onBackendUrlChange && (
<div className="space-y-3">

View file

@ -0,0 +1,56 @@
import { useState } from 'react';
import { Eye, EyeOff } from '@/lib/lucide-icons';
interface SecretInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
/** Accessible name for the field — the visible `<label>` is the caller's. */
label: string;
/** Accessible name for the toggle in each of its two states. */
revealLabel: string;
hideLabel: string;
}
/**
* Masked text field with a reveal toggle, for secrets the user pastes in:
* deploy access tokens, provider API keys. `type="password"` until the user
* asks otherwise, and the value is never logged or put in a URL by anything
* here.
*
* Reveal state lives inside the component, so a caller that unmounts the field
* (the settings panel returns `null` when closed) reopens masked.
*/
export const SecretInput = ({
value,
onChange,
placeholder,
label,
revealLabel,
hideLabel,
}: SecretInputProps) => {
const [isRevealed, setIsRevealed] = useState(false);
return (
<div className="relative">
<input
type={isRevealed ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
spellCheck={false}
aria-label={label}
placeholder={placeholder}
className="w-full rounded-xl border border-border-subtle bg-elevated px-4 py-3 pr-11 font-mono text-sm text-text-primary transition-all outline-none placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20"
/>
<button
type="button"
onClick={() => setIsRevealed((prev) => !prev)}
aria-label={isRevealed ? hideLabel : revealLabel}
className="absolute top-1/2 right-3 -translate-y-1/2 text-text-muted transition-colors hover:text-text-primary"
>
{isRevealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
);
};

View file

@ -8,6 +8,21 @@ export const DEFAULT_BACKEND_URL =
export const DEFAULT_OLLAMA_BASE_URL = 'http://localhost:11434';
export const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
/**
* sessionStorage key for the deploy access token sent as
* `Authorization: Bearer <token>` on every `/api/*` request.
*
* sessionStorage, not localStorage: `core/llm/settings-service.ts` already
* migrated provider API keys off localStorage and deletes the legacy copy. A
* deploy token is the same class of secret, so it gets the same one-session
* lifetime.
*
* Never a cookie: the browser attaches cookies to cross-site requests and
* forwards them blind, and the public edge strips `Origin` before proxying, so
* no CSRF backstop is left to catch it. A header is not sent automatically.
*/
export const AUTH_TOKEN_STORAGE_KEY = 'gitnexus-auth-token';
/**
* Default node-count above which the WebUI connects in chat-only mode (skips
* the full graph download). Grounded in sigma.js/graphology prior art: ~10K

View file

@ -34,7 +34,7 @@ import {
readFile as backendReadFile,
startEmbeddings as backendStartEmbeddings,
streamEmbeddingProgress,
probeBackend,
probeBackendStatus,
// Aliased: switchRepo declares a local `let repoIdentity` that would shadow
// a plain named import of this helper.
repoIdentity as repoIdentityOf,
@ -516,7 +516,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
}, []);
const isDatabaseReady = useCallback(async (): Promise<boolean> => {
return probeBackend();
return (await probeBackendStatus()) === 'ok';
}, []);
// Embedding methods — now trigger server-side via /api/embed

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { probeBackend, setBackendUrl as setServiceUrl } from '../services/backend-client';
import { probeBackendStatus, setBackendUrl as setServiceUrl } from '../services/backend-client';
import { DEFAULT_BACKEND_URL } from '../config/ui-constants';
// ── localStorage keys ────────────────────────────────────────────────────────
@ -13,6 +13,12 @@ export interface UseBackendResult {
isConnected: boolean;
/** Currently checking connection */
isProbing: boolean;
/**
* The last probe got a 401 from the public edge's token gate. The deploy is
* reachable; it just needs an access token. Use it to prompt for one instead
* of telling the user to start a server that is already running.
*/
isUnauthorized: boolean;
/** Current backend URL */
backendUrl: string;
/** Start polling for server availability (setTimeout chain, visibility-aware) */
@ -36,6 +42,7 @@ export function useBackend(): UseBackendResult {
const [isConnected, setIsConnected] = useState(false);
const [isProbing, setIsProbing] = useState(false);
const [isUnauthorized, setIsUnauthorized] = useState(false);
// Race-condition guard: monotonically increasing probe ID
const probeIdRef = useRef(0);
@ -47,13 +54,15 @@ export function useBackend(): UseBackendResult {
setIsProbing(true);
try {
const ok = await probeBackend();
const status = await probeBackendStatus();
if (id !== probeIdRef.current) return false;
setIsConnected(ok);
return ok;
setIsConnected(status === 'ok');
setIsUnauthorized(status === 'unauthorized');
return status === 'ok';
} catch {
if (id === probeIdRef.current) {
setIsConnected(false);
setIsUnauthorized(false);
}
return false;
} finally {
@ -147,6 +156,7 @@ export function useBackend(): UseBackendResult {
return {
isConnected,
isProbing,
isUnauthorized,
backendUrl,
startPolling,
stopPolling,

View file

@ -14,6 +14,8 @@ export function formatBackendError(error: unknown, t: TFunction): string {
return t('errors:backend.rateLimited', { seconds, defaultValue: fallback });
case 'not_found':
return t('errors:backend.notFound', { defaultValue: fallback });
case 'unauthorized':
return t('errors:backend.unauthorized', { defaultValue: fallback });
case 'origin_blocked':
return t('errors:backend.originBlocked', { defaultValue: fallback });
case 'client':

View file

@ -14,6 +14,7 @@
"timeout": "The server took too long to respond. Try again in a moment.",
"rateLimited": "Too many requests. Try again in {{seconds}}s.",
"notFound": "The requested repository or resource was not found.",
"unauthorized": "This GitNexus deploy requires an access token. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable, then paste it into Settings.",
"originBlocked": "This action isn't available from the hosted UI. Open GitNexus from the server's own address (e.g. http://localhost:4747) to continue.",
"client": "Request failed: {{message}}",
"server": "Server error: {{message}}"

View file

@ -6,6 +6,17 @@
"connected": "Connected",
"notConnected": "Not connected",
"runServeHint": "Run `gitnexus serve` to connect the web UI to a local backend.",
"accessToken": {
"label": "Access Token",
"placeholder": "Paste your deploy access token",
"hint": "Required only for a gated deploy. Find it in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable. Leave empty for a local server.",
"title": "This deploy requires an access token",
"promptHint": "The GitNexus server is running but gated. Find the token in your Render dashboard under the gitnexus-web service's GITNEXUS_SERVE_AUTH_TOKEN environment variable.",
"connect": "Connect",
"reveal": "Show access token",
"hide": "Hide access token",
"sessionNote": "Stored for this browser session only. You will re-enter it in a new tab or after closing the browser."
},
"provider": "Provider",
"apiKey": "API Key",
"learnMore": "Learn more",

View file

@ -14,6 +14,7 @@
"timeout": "服务器响应超时,请稍后重试。",
"rateLimited": "请求过于频繁,请在 {{seconds}} 秒后重试。",
"notFound": "未找到请求的仓库或资源。",
"unauthorized": "GitNexus 部署需要访问令牌。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看,然后粘贴到「设置」中。",
"originBlocked": "此操作无法从托管界面执行。请通过服务器自身地址(例如 http://localhost:4747打开 GitNexus 后再继续。",
"client": "请求失败:{{message}}",
"server": "服务器错误:{{message}}"

View file

@ -6,6 +6,17 @@
"connected": "已连接",
"notConnected": "未连接",
"runServeHint": "运行 `gitnexus serve` 将 Web UI 连接到本地后端。",
"accessToken": {
"label": "访问令牌",
"placeholder": "粘贴部署访问令牌",
"hint": "仅在启用访问控制的部署中需要。可在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中找到。本地服务器请留空。",
"title": "此部署需要访问令牌",
"promptHint": "GitNexus 服务器正在运行,但已启用访问控制。请在 Render 控制台的 gitnexus-web 服务的 GITNEXUS_SERVE_AUTH_TOKEN 环境变量中查看该令牌。",
"connect": "连接",
"reveal": "显示访问令牌",
"hide": "隐藏访问令牌",
"sessionNote": "仅在当前浏览器会话中保存。新标签页或重新打开浏览器后需要重新输入。"
},
"provider": "提供商",
"apiKey": "API Key",
"learnMore": "了解更多",

View file

@ -8,7 +8,11 @@
import type { GraphNode, GraphRelationship } from 'gitnexus-shared';
import { CircuitOpenError, ResilientFetchExhaustedError, resilientFetch } from 'gitnexus-shared';
import { LARGE_GRAPH_NODE_THRESHOLD, LARGE_GRAPH_EDGE_THRESHOLD } from '../config/ui-constants';
import {
AUTH_TOKEN_STORAGE_KEY,
LARGE_GRAPH_NODE_THRESHOLD,
LARGE_GRAPH_EDGE_THRESHOLD,
} from '../config/ui-constants';
import { decideSkipGraph } from '../lib/graph-load-decision';
// ── Types ──────────────────────────────────────────────────────────────────
@ -92,7 +96,12 @@ export class BackendError extends Error {
// The write-route same-host Origin guard rejected this request (HTTP 403
// with `{ code: 'origin_not_allowed' }`). Distinct from a generic `client`
// 403 so the UI can show actionable "open the local UI" guidance.
| 'origin_blocked',
| 'origin_blocked'
// The public edge rejected this request for a missing or wrong deploy
// access token (HTTP 401 with `{ code: 'unauthorized' }`). Distinct from a
// generic `client` 4xx so the UI can prompt for the token instead of
// showing a raw error.
| 'unauthorized',
/**
* Milliseconds until the caller should retry. Populated for rate-limited
* responses (HTTP 429) from the server's `Retry-After` header. `undefined`
@ -129,32 +138,73 @@ export interface SSEHandlers<T = unknown> {
onMessage?: (data: T) => void;
onComplete?: (data: T) => void;
onError?: (error: string) => void;
/** Fires on every successful (re)connection, once the stream is readable. */
onOpen?: () => void;
/**
* Fires each time a reconnect is scheduled after a drop. Callers that want
* "notify once per outage" dedupe on their side, resetting in `onOpen`.
*/
onReconnecting?: () => void;
}
export interface SSEOptions {
/** Reconnect attempts after a drop. `Infinity` for an indefinite stream. Default 3. */
maxRetries?: number;
/** First backoff delay; doubles per attempt. Default 1000ms. */
baseDelayMs?: number;
/** Upper bound on the doubling backoff. Default unbounded. */
capDelayMs?: number;
/**
* Reconnect on a non-OK HTTP response as well as on a network drop. Off by
* default: a job-progress stream that 4xx's is a real, terminal error the
* caller has to see. A long-lived liveness stream turns it on, so a 401 from
* the edge's token gate resolves itself once a token is entered.
*/
retryOnHttpError?: boolean;
}
/**
* Generic SSE stream consumer using fetch + ReadableStream.
* Returns an AbortController to cancel the stream.
* Automatically reconnects on network drops (up to 3 retries with backoff).
* Automatically reconnects on network drops (up to `maxRetries` with backoff).
*
* fetch-based rather than `EventSource` because `EventSource` cannot send
* custom headers, and every `/api/*` request needs the `Authorization` header
* to clear the public edge's token gate.
*/
export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): AbortController {
export function streamSSE<T = unknown>(
url: string,
handlers: SSEHandlers<T>,
options: SSEOptions = {},
): AbortController {
const controller = new AbortController();
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1_000;
const maxRetries = options.maxRetries ?? 3;
const baseDelayMs = options.baseDelayMs ?? 1_000;
const capDelayMs = options.capDelayMs ?? Infinity;
let lastEventId = '';
/** Schedule the next attempt. Returns false when the budget is spent. */
const scheduleRetry = (retryCount: number): boolean => {
if (controller.signal.aborted || retryCount >= maxRetries) return false;
handlers.onReconnecting?.();
setTimeout(() => connect(retryCount + 1), Math.min(baseDelayMs * 2 ** retryCount, capDelayMs));
return true;
};
const connect = (retryCount: number) => {
if (controller.signal.aborted) return;
(async () => {
try {
const headers: Record<string, string> = {};
const headers = withAuthHeader(new Headers());
if (lastEventId) {
headers['Last-Event-ID'] = lastEventId;
headers.set('Last-Event-ID', lastEventId);
}
const response = await fetch(url, { signal: controller.signal, headers });
if (!response.ok) {
if (options.retryOnHttpError && scheduleRetry(retryCount)) return;
handlers.onError?.(`Server returned ${response.status}`);
return;
}
@ -167,6 +217,7 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
// Reset retry count on successful connection
retryCount = 0;
handlers.onOpen?.();
const decoder = new TextDecoder();
let buffer = '';
@ -213,15 +264,11 @@ export function streamSSE<T = unknown>(url: string, handlers: SSEHandlers<T>): A
}
// Stream ended without terminal event — try to reconnect
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
}
scheduleRetry(retryCount);
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') return;
// Network error — attempt reconnect with backoff
if (!controller.signal.aborted && retryCount < MAX_RETRIES) {
setTimeout(() => connect(retryCount + 1), BASE_DELAY_MS * 2 ** retryCount);
} else {
if (!scheduleRetry(retryCount)) {
handlers.onError?.(err instanceof Error ? err.message : 'Stream error');
}
}
@ -288,6 +335,67 @@ export function normalizeServerUrl(input: string): string {
return url;
}
// ── Access token ───────────────────────────────────────────────────────────
/**
* Deploy access token, sent as `Authorization: Bearer <token>` on every
* `/api/*` request. `''` when the deploy has no gate, which is a valid state;
* `null` means "not yet read from storage". See AUTH_TOKEN_STORAGE_KEY for why
* sessionStorage and why a header rather than a cookie.
*/
let _authToken: string | null = null;
const readStoredAuthToken = (): string => {
try {
if (typeof sessionStorage === 'undefined') return '';
return sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY) ?? '';
} catch {
// Storage can throw in private browsing modes — treat as no token.
return '';
}
};
/** The current access token, or `''` when the deploy is ungated. */
export const getAuthToken = (): string => {
if (_authToken === null) {
_authToken = readStoredAuthToken();
}
return _authToken;
};
/**
* Store the access token for this browser session. A whitespace-only token
* clears it, which is how the header is disabled for an ungated local backend.
*/
export const setAuthToken = (token: string): void => {
const trimmed = token.trim();
_authToken = trimmed;
try {
if (typeof sessionStorage === 'undefined') return;
if (trimmed) {
sessionStorage.setItem(AUTH_TOKEN_STORAGE_KEY, trimmed);
} else {
sessionStorage.removeItem(AUTH_TOKEN_STORAGE_KEY);
}
} catch (error) {
// Persist failure is non-fatal: the in-memory token still authorizes this
// tab's requests. Log the failure, never the token.
console.warn('Failed to persist the GitNexus access token to sessionStorage:', error);
}
};
/**
* Add `Authorization` to a header set, in place. With no token the header is
* omitted rather than sent empty: an empty credential is malformed, not absent.
*/
const withAuthHeader = (headers: Headers): Headers => {
const token = getAuthToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
};
// ── Internal Helpers ───────────────────────────────────────────────────────
const DEFAULT_TIMEOUT_MS = 30_000;
@ -323,6 +431,12 @@ const fetchWithTimeout = async (
const externalSignal = init.signal;
const signal = externalSignal ? AbortSignal.any([timeoutSignal, externalSignal]) : timeoutSignal;
// Single chokepoint for the deploy access token — every REST call routes
// through here. `Headers` rather than an object spread because callers pass
// their own `headers` (e.g. `Content-Type: application/json`) and a spread
// would drop one side or the other depending on ordering.
const headers = withAuthHeader(new Headers(init.headers));
const method = (init.method ?? 'GET').toUpperCase();
const isIdempotent = IDEMPOTENT_METHODS.has(method);
const maxAttempts = isIdempotent || forceRetry ? 2 : 1;
@ -348,7 +462,7 @@ const fetchWithTimeout = async (
// single-attempt to avoid duplicate side effects.
const response = await resilientFetch(
url,
{ ...init, signal },
{ ...init, headers, signal },
{
breakerKey,
retry: { maxAttempts, baseDelayMs: 250, capDelayMs: 1500 },
@ -412,13 +526,17 @@ const assertOk = async (response: Response): Promise<void> => {
? 'not_found'
: response.status === 429
? 'rate_limited'
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
: // The public edge's token gate returns 401 with this discriminator;
// surface it as a distinct code so the UI can prompt for the token.
bodyCode === 'unauthorized'
? 'unauthorized'
: // The write-route Origin guard returns 403 with this discriminator;
// surface it as a distinct code so the UI can give actionable guidance.
bodyCode === 'origin_not_allowed'
? 'origin_blocked'
: response.status >= 400 && response.status < 500
? 'client'
: 'server';
// Retry-After is the standard HTTP signal for when the client may try again.
// express-rate-limit emits it on 429 with seconds (integer) or HTTP-date.
@ -460,6 +578,8 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
return response.json() as Promise<ServerInfo>;
};
const HEARTBEAT_MAX_BACKOFF_MS = 15_000;
/**
* Connect an SSE heartbeat to the backend. Retries indefinitely with capped
* exponential backoff so transient hiccups don't reset the UI.
@ -468,53 +588,42 @@ export const fetchServerInfo = async (): Promise<ServerInfo> => {
* - `onReconnecting` fires on the first retry after a drop use it to show
* a "reconnecting" banner while keeping the current view intact.
*
* Returns a cleanup function that tears down the EventSource and timers.
* Runs on `streamSSE` rather than `EventSource`: `EventSource` cannot send
* custom headers, so it can't clear the edge's token gate, and the heartbeat
* would 401 forever on a gated deploy. `streamSSE` reconnects on a non-OK
* response here (`retryOnHttpError`), so a 401 recovers on its own once the
* user enters a token instead of needing a page reload.
*
* Returns a cleanup function that aborts the stream and its pending retry.
*/
export const connectHeartbeat = (
onConnect: () => void,
onReconnecting: () => void,
): (() => void) => {
let closed = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let es: EventSource | null = null;
let attempt = 0;
/** Whether we've already fired onReconnecting for the current drop. */
let notifiedReconnecting = false;
const MAX_BACKOFF_MS = 15_000;
const connect = () => {
if (closed) return;
es = new EventSource(`${_backendUrl}/api/heartbeat`);
es.onopen = () => {
if (!closed) {
attempt = 0;
const controller = streamSSE(
`${_backendUrl}/api/heartbeat`,
{
onOpen: () => {
notifiedReconnecting = false;
onConnect();
}
};
es.onerror = () => {
es?.close();
es = null;
if (closed) return;
if (!notifiedReconnecting) {
},
onReconnecting: () => {
if (notifiedReconnecting) return;
notifiedReconnecting = true;
onReconnecting();
}
},
},
{
maxRetries: Infinity,
capDelayMs: HEARTBEAT_MAX_BACKOFF_MS,
retryOnHttpError: true,
},
);
const delay = Math.min(1_000 * Math.pow(2, attempt), MAX_BACKOFF_MS);
attempt++;
retryTimer = setTimeout(connect, delay);
};
};
connect();
return () => {
closed = true;
es?.close();
if (retryTimer) clearTimeout(retryTimer);
};
return () => controller.abort();
};
/** Delete a repo's index and unregister it. */
@ -528,13 +637,29 @@ export const deleteRepo = async (repoName: string): Promise<void> => {
await assertOk(response);
};
/** Probe the backend. Returns true if reachable. */
export const probeBackend = async (): Promise<boolean> => {
/**
* Outcome of a backend probe. A single value rather than a pair of booleans,
* so "reachable and gated at the same time" cannot be represented:
* - `ok` answered 200, reachable and authorized.
* - `unauthorized` the public edge rejected the probe for a missing or wrong
* access token (401). The deploy is up; the fix is to enter a token, not to
* start a server.
* - `unreachable` no usable answer: a transport failure, a timeout, or any
* other status.
*/
export type BackendProbeStatus = 'ok' | 'unauthorized' | 'unreachable';
/**
* Probe the backend, distinguishing "not there" from "there but gated".
* Never throws a probe failure is a state, not an error.
*/
export const probeBackendStatus = async (): Promise<BackendProbeStatus> => {
try {
const response = await fetchWithTimeout(`${_backendUrl}/api/repos`, {}, PROBE_TIMEOUT_MS);
return response.status === 200;
if (response.status === 200) return 'ok';
return response.status === 401 ? 'unauthorized' : 'unreachable';
} catch {
return false;
return 'unreachable';
}
};

View file

@ -0,0 +1,60 @@
/**
* The onboarding half of the edge token gate: a 401 has to read as "enter a
* token", not "start a server", and the token the user enters has to reach
* sessionStorage and only sessionStorage.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AccessTokenPrompt } from '../../src/components/AccessTokenPrompt';
import { i18nReady } from '../../src/i18n';
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
const TOKEN = 'deploy-token-abc123';
describe('AccessTokenPrompt', () => {
beforeEach(async () => {
await i18nReady;
setAuthToken('');
});
afterEach(() => {
setAuthToken('');
});
it('stores an entered token in sessionStorage, never localStorage', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<AccessTokenPrompt onSubmit={onSubmit} />);
const input = screen.getByLabelText('Access Token');
// Masked by default — the token is never rendered in plain text unasked.
expect(input).toHaveAttribute('type', 'password');
await user.type(input, TOKEN);
await user.click(screen.getByRole('button', { name: 'Connect' }));
expect(getAuthToken()).toBe(TOKEN);
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
expect(onSubmit).toHaveBeenCalledOnce();
});
it('reveals and re-masks the token on request', async () => {
const user = userEvent.setup();
render(<AccessTokenPrompt />);
await user.click(screen.getByRole('button', { name: 'Show access token' }));
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'text');
await user.click(screen.getByRole('button', { name: 'Hide access token' }));
expect(screen.getByLabelText('Access Token')).toHaveAttribute('type', 'password');
});
it('points at the Render environment variable that holds the token', () => {
render(<AccessTokenPrompt />);
expect(screen.getByText(/GITNEXUS_SERVE_AUTH_TOKEN/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,239 @@
/**
* Deploy access token plumbing in backend-client.
*
* The public edge (`docker-server.mjs`) gates every `/api/*` request behind
* `Authorization: Bearer <token>` and answers 401 with `{ code: 'unauthorized' }`
* otherwise. These tests pin the three things that make the browser half work:
* the header reaches every request path, an absent token sends no header at all,
* and the token never lands anywhere but sessionStorage.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { __resetBreakerRegistry__ } from 'gitnexus-shared/test-helpers';
import {
BackendError,
fetchRepos,
getAuthToken,
probeBackendStatus,
runQuery,
setAuthToken,
setBackendUrl,
streamSSE,
} from '../../src/services/backend-client';
import { AUTH_TOKEN_STORAGE_KEY } from '../../src/config/ui-constants';
const BASE = 'http://localhost:4747';
const TOKEN = 'deploy-token-abc123';
/** Headers of the nth fetch call, normalized to a `Headers` instance. */
const headersOf = (fetchMock: ReturnType<typeof vi.fn>, call = 0): Headers =>
new Headers((fetchMock.mock.calls[call]?.[1] as RequestInit | undefined)?.headers);
const jsonOk = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
describe('backend-client access token', () => {
beforeEach(() => {
__resetBreakerRegistry__();
setBackendUrl(BASE);
setAuthToken('');
});
afterEach(() => {
setAuthToken('');
vi.unstubAllGlobals();
});
it('sends Authorization: Bearer <token> when a token is set', async () => {
const fetchMock = vi.fn(async () => jsonOk([]));
vi.stubGlobal('fetch', fetchMock);
setAuthToken(TOKEN);
await fetchRepos();
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
});
it('sends no Authorization header at all when no token is set', async () => {
const fetchMock = vi.fn(async () => jsonOk([]));
vi.stubGlobal('fetch', fetchMock);
await fetchRepos();
// Absent, not empty — an empty credential is malformed, not missing.
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
});
it('trims the token and treats a whitespace-only token as absent', async () => {
const fetchMock = vi.fn(async () => jsonOk([]));
vi.stubGlobal('fetch', fetchMock);
setAuthToken(` ${TOKEN} `);
await fetchRepos();
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
setAuthToken(' ');
await fetchRepos();
expect(headersOf(fetchMock, 1).has('Authorization')).toBe(false);
});
it("preserves a caller's own headers alongside Authorization", async () => {
const fetchMock = vi.fn(async () => jsonOk({ result: [] }));
vi.stubGlobal('fetch', fetchMock);
setAuthToken(TOKEN);
// runQuery passes `Content-Type: application/json` of its own — the
// `Headers` merge has to keep both, which an object spread would not.
await runQuery('MATCH (n) RETURN n');
const headers = headersOf(fetchMock);
expect(headers.get('Authorization')).toBe(`Bearer ${TOKEN}`);
expect(headers.get('Content-Type')).toBe('application/json');
});
it('surfaces a 401 with code "unauthorized" as BackendError.code === "unauthorized"', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(JSON.stringify({ error: 'unauthorized', code: 'unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json', 'WWW-Authenticate': 'Bearer' },
}),
),
);
const error = await fetchRepos().catch((e: unknown) => e);
expect(error).toBeInstanceOf(BackendError);
expect((error as BackendError).code).toBe('unauthorized');
expect((error as BackendError).status).toBe(401);
});
it('keeps a 401 without the discriminator as a generic client error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('nope', { status: 401 })),
);
const error = await fetchRepos().catch((e: unknown) => e);
expect((error as BackendError).code).toBe('client');
});
it('stores the token in sessionStorage and never in localStorage', () => {
setAuthToken(TOKEN);
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBe(TOKEN);
expect(localStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
expect(getAuthToken()).toBe(TOKEN);
setAuthToken('');
expect(sessionStorage.getItem(AUTH_TOKEN_STORAGE_KEY)).toBeNull();
expect(getAuthToken()).toBe('');
});
describe('probeBackendStatus', () => {
it('reports a 401 as unauthorized rather than plain unreachability', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 401 })),
);
await expect(probeBackendStatus()).resolves.toBe('unauthorized');
});
it('reports a genuinely absent backend as unreachable, not gated', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new TypeError('fetch failed');
}),
);
await expect(probeBackendStatus()).resolves.toBe('unreachable');
});
it('reports a 200 as ok', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => jsonOk([])),
);
await expect(probeBackendStatus()).resolves.toBe('ok');
});
});
describe('streamSSE', () => {
/** A response body that emits `chunks` then closes the stream. */
const sseResponse = (chunks: string[]) =>
new Response(
new ReadableStream<Uint8Array>({
start(c) {
const encoder = new TextEncoder();
for (const chunk of chunks) c.enqueue(encoder.encode(chunk));
c.close();
},
}),
{ status: 200, headers: { 'Content-Type': 'text/event-stream' } },
);
it('sends the token, and keeps it alongside Last-Event-ID on reconnect', async () => {
// First connection ends after one identified event, so the retry carries
// `Last-Event-ID`. Both headers must be present on that second attempt.
const fetchMock = vi.fn(async () => sseResponse(['id: 42\ndata: {"percent":10}\n\n']));
vi.stubGlobal('fetch', fetchMock);
setAuthToken(TOKEN);
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { baseDelayMs: 0 });
await vi.waitFor(() => expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2));
controller.abort();
expect(headersOf(fetchMock).get('Authorization')).toBe(`Bearer ${TOKEN}`);
expect(headersOf(fetchMock).has('Last-Event-ID')).toBe(false);
const retryHeaders = headersOf(fetchMock, 1);
expect(retryHeaders.get('Authorization')).toBe(`Bearer ${TOKEN}`);
expect(retryHeaders.get('Last-Event-ID')).toBe('42');
});
it('sends no Authorization header when no token is set', async () => {
const fetchMock = vi.fn(async () => sseResponse(['data: {"percent":10}\n\n']));
vi.stubGlobal('fetch', fetchMock);
const controller = streamSSE(`${BASE}/api/analyze/j1/progress`, {}, { maxRetries: 0 });
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
controller.abort();
expect(headersOf(fetchMock).has('Authorization')).toBe(false);
});
it('reports a non-OK response as an error and does not retry by default', async () => {
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
streamSSE(`${BASE}/api/analyze/j1/progress`, { onError }, { baseDelayMs: 0 });
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith('Server returned 401'));
expect(fetchMock).toHaveBeenCalledOnce();
});
it('retries a non-OK response when retryOnHttpError is set', async () => {
const fetchMock = vi.fn(async () => new Response('nope', { status: 401 }));
vi.stubGlobal('fetch', fetchMock);
const onError = vi.fn();
const controller = streamSSE(
`${BASE}/api/heartbeat`,
{ onError },
{ baseDelayMs: 0, maxRetries: 2, retryOnHttpError: true },
);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
controller.abort();
// Budget spent → the caller finally hears about it.
expect(onError).toHaveBeenCalledWith('Server returned 401');
});
});
});

View file

@ -1,150 +1,223 @@
/**
* `connectHeartbeat` runs on `streamSSE` (fetch + ReadableStream), not
* `EventSource`, because `EventSource` cannot send custom headers and every
* `/api/*` request needs `Authorization: Bearer <token>` to clear the public
* edge's token gate.
*
* These tests pin the behavior `EventSource` used to provide for free
* indefinite reconnect with capped backoff, one "reconnecting" notification per
* outage, teardown on cleanup plus the two things the migration exists for:
* the token header, and a 401 that recovers instead of giving up.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { connectHeartbeat } from '../../src/services/backend-client';
import { connectHeartbeat, setAuthToken } from '../../src/services/backend-client';
// Mock EventSource to simulate SSE behavior
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
close() {
this.closed = true;
}
/** A live fake SSE connection, closable from the test. */
interface FakeConnection {
/** End the stream cleanly — the client sees a drop and reconnects. */
drop: () => void;
}
let lastEventSource: MockEventSource | null = null;
let connections: FakeConnection[] = [];
/** HTTP statuses to answer with, in order. Exhausted → 200. */
let statusQueue: number[] = [];
let fetchMock: ReturnType<typeof vi.fn>;
/** Let pending promises settle without advancing the clock. */
const flush = () => vi.advanceTimersByTimeAsync(0);
beforeEach(() => {
lastEventSource = null;
// vitest 4 enforces that mock implementations used with `new` must have a
// [[Construct]] slot. Arrow functions don't, so we use a regular function
// declaration here. The production code calls `new EventSource(...)`.
vi.stubGlobal(
'EventSource',
vi.fn().mockImplementation(function () {
lastEventSource = new MockEventSource();
return lastEventSource;
}),
);
connections = [];
statusQueue = [];
setAuthToken('');
fetchMock = vi.fn(async () => {
const status = statusQueue.shift() ?? 200;
if (status !== 200) return new Response('nope', { status });
let streamController!: ReadableStreamDefaultController<Uint8Array>;
const body = new ReadableStream<Uint8Array>({
start(c) {
streamController = c;
// The server's initial ":ok" comment — proves comments are tolerated.
c.enqueue(new TextEncoder().encode(':ok\n\n'));
},
});
connections.push({ drop: () => streamController.close() });
return new Response(body, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
});
});
vi.stubGlobal('fetch', fetchMock);
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
setAuthToken('');
});
describe('connectHeartbeat', () => {
it('calls onConnect when EventSource opens', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
expect(onReconnecting).not.toHaveBeenCalled();
});
it('calls onReconnecting on first error, then retries', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate connection drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
expect(lastEventSource!.closed).toBe(true);
// Advance past first retry delay (1s)
vi.advanceTimersByTime(1_000);
// A new EventSource should have been created
expect(EventSource).toHaveBeenCalledTimes(2);
});
it('fires onReconnecting only once per disconnect', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// First error
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Second retry fires error again
vi.advanceTimersByTime(1_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
// Third retry fires error
vi.advanceTimersByTime(2_000);
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce(); // still 1
});
it('retries indefinitely instead of giving up after 3 attempts', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Simulate 10 consecutive failures — should never stop retrying
for (let i = 0; i < 10; i++) {
lastEventSource!.onerror!();
// Advance past the max backoff (15s) to ensure the next retry fires
vi.advanceTimersByTime(16_000);
}
// Should have created 11 EventSources (1 initial + 10 retries)
expect(EventSource).toHaveBeenCalledTimes(11);
});
it('resets reconnecting state when connection recovers', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Drop
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledOnce();
// Retry succeeds
vi.advanceTimersByTime(1_000);
lastEventSource!.onopen!();
expect(onConnect).toHaveBeenCalledOnce();
// Drop again — should fire onReconnecting again (reset after recovery)
lastEventSource!.onerror!();
expect(onReconnecting).toHaveBeenCalledTimes(2);
});
it('caps backoff at 15 seconds', () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
connectHeartbeat(onConnect, onReconnecting);
// Fail many times to push backoff past the cap
for (let i = 0; i < 6; i++) {
lastEventSource!.onerror!();
// The delay for attempt i is min(1000 * 2^i, 15000)
// i=0: 1s, i=1: 2s, i=2: 4s, i=3: 8s, i=4: 15s (capped), i=5: 15s (capped)
vi.advanceTimersByTime(16_000);
}
// All retries should have fired — 7 EventSources total
expect(EventSource).toHaveBeenCalledTimes(7);
});
it('stops retrying when cleanup is called', () => {
it('calls onConnect once the stream is readable', async () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
lastEventSource!.onerror!();
await flush();
expect(onConnect).toHaveBeenCalledOnce();
expect(onReconnecting).not.toHaveBeenCalled();
cleanup();
});
it('sends the access token as an Authorization header', async () => {
setAuthToken('deploy-token-abc123');
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
await flush();
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
expect(headers.get('Authorization')).toBe('Bearer deploy-token-abc123');
cleanup();
});
it('sends no Authorization header on an ungated deploy', async () => {
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
await flush();
const headers = new Headers((fetchMock.mock.calls[0][1] as RequestInit).headers);
expect(headers.has('Authorization')).toBe(false);
cleanup();
});
it('calls onReconnecting on first drop, then retries', async () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
await flush();
connections[0].drop();
await flush();
expect(onReconnecting).toHaveBeenCalledOnce();
// Advance past the first retry delay (1s)
await vi.advanceTimersByTimeAsync(1_000);
expect(fetchMock).toHaveBeenCalledTimes(2);
cleanup();
});
it('fires onReconnecting only once per outage', async () => {
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(vi.fn(), onReconnecting);
await flush();
// Every reconnect attempt answers 401 — the stream never reopens, so the
// banner must not re-fire on each attempt.
statusQueue = [401, 401, 401];
connections[0].drop();
await vi.advanceTimersByTimeAsync(5_000);
expect(fetchMock.mock.calls.length).toBeGreaterThan(2);
expect(onReconnecting).toHaveBeenCalledOnce();
cleanup();
});
it('retries indefinitely instead of giving up after 3 attempts', async () => {
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
await flush();
for (let i = 0; i < 10; i++) {
connections[i].drop();
// Advance past the max backoff (15s) so the next attempt always fires
await vi.advanceTimersByTimeAsync(16_000);
}
// 1 initial connection + 10 reconnects
expect(fetchMock).toHaveBeenCalledTimes(11);
cleanup();
});
it('reconnects after a 401 so a token entered later recovers the stream', async () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
await flush();
expect(onConnect).toHaveBeenCalledOnce();
// The gate starts rejecting (token cleared / never entered)…
statusQueue = [401, 401];
connections[0].drop();
await vi.advanceTimersByTimeAsync(5_000);
expect(onConnect).toHaveBeenCalledOnce();
expect(onReconnecting).toHaveBeenCalledOnce();
// …and once a valid token is in place the next attempt succeeds on its own.
await vi.advanceTimersByTimeAsync(16_000);
expect(onConnect).toHaveBeenCalledTimes(2);
cleanup();
});
it('resets reconnecting state when the connection recovers', async () => {
const onConnect = vi.fn();
const onReconnecting = vi.fn();
const cleanup = connectHeartbeat(onConnect, onReconnecting);
await flush();
connections[0].drop();
await flush();
expect(onReconnecting).toHaveBeenCalledOnce();
// Retry succeeds
await vi.advanceTimersByTimeAsync(1_000);
expect(onConnect).toHaveBeenCalledTimes(2);
// Drop again — a fresh outage notifies again
connections[1].drop();
await flush();
expect(onReconnecting).toHaveBeenCalledTimes(2);
cleanup();
});
it('caps backoff at 15 seconds', async () => {
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
await flush();
// Every attempt 401s, so nothing reopens and the retry counter keeps
// climbing — the doubling backoff would reach 16s on the 5th retry.
statusQueue = Array.from({ length: 10 }, () => 401);
connections[0].drop();
await flush();
// Walk the uncapped part of the schedule exactly: 1s, 2s, 4s, 8s.
for (const delay of [1_000, 2_000, 4_000, 8_000]) {
await vi.advanceTimersByTimeAsync(delay);
}
expect(fetchMock).toHaveBeenCalledTimes(5);
// The next delay doubles to 16s, so the cap is what makes this retry fire
// at 15s. Not a millisecond sooner, and not at 16s.
await vi.advanceTimersByTimeAsync(14_999);
expect(fetchMock).toHaveBeenCalledTimes(5);
await vi.advanceTimersByTimeAsync(1);
expect(fetchMock).toHaveBeenCalledTimes(6);
cleanup();
});
it('stops retrying when cleanup is called', async () => {
const cleanup = connectHeartbeat(vi.fn(), vi.fn());
await flush();
connections[0].drop();
await flush();
cleanup();
// Advance time — no new EventSource should be created
vi.advanceTimersByTime(30_000);
expect(EventSource).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_000);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View file

@ -0,0 +1,49 @@
/**
* The settings panel's access-token field persists on Save, with every other
* field, rather than on each keystroke. Save is the only "committed" affordance
* the panel has, and a half-typed token would otherwise ride the next probe to
* the backend.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsPanel } from '../../src/components/SettingsPanel';
import { i18nReady } from '../../src/i18n';
import { getAuthToken, setAuthToken } from '../../src/services/backend-client';
const TOKEN = 'deploy-token-abc123';
describe('SettingsPanel access token', () => {
beforeEach(async () => {
await i18nReady;
setAuthToken('');
});
afterEach(() => {
setAuthToken('');
});
it('holds a typed token locally until Save', async () => {
const user = userEvent.setup();
render(<SettingsPanel isOpen onClose={vi.fn()} />);
await user.type(screen.getByLabelText('Access Token'), TOKEN);
expect(getAuthToken()).toBe('');
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
expect(getAuthToken()).toBe(TOKEN);
});
it('clears a stored token when the field is emptied and saved', async () => {
setAuthToken(TOKEN);
const user = userEvent.setup();
render(<SettingsPanel isOpen onClose={vi.fn()} />);
await user.clear(screen.getByLabelText('Access Token'));
await user.click(screen.getByRole('button', { name: 'Save Settings' }));
// An empty token is a valid state — an ungated local backend needs no header.
expect(getAuthToken()).toBe('');
});
});

View file

@ -0,0 +1,144 @@
/**
* Invariants of the repo-root `render.yaml`, which nothing else in CI parses.
*
* Two kinds: couplings the Blueprint documents but cannot enforce
* (`disk.mountPath` = `GITNEXUS_HOME`, `PORT` = the port `Dockerfile.cli` binds),
* and the choices that keep the deploy closed (the API server is private, the
* public proxy always has a token). Without these, a plausible-looking edit puts
* the API back on the internet with every other test green.
*
* Full schema conformance is `render blueprints validate render.yaml`, which
* needs network and an account; checked here is only the pragma pointing at it.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { load } from 'js-yaml';
const monorepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const blueprintSource = readFileSync(path.join(monorepoRoot, 'render.yaml'), 'utf8');
const dockerfileSource = readFileSync(path.join(monorepoRoot, 'Dockerfile.cli'), 'utf8');
const SCHEMA_PRAGMA = '# yaml-language-server: $schema=https://render.com/schema/render.yaml.json';
interface EnvVar {
key: string;
value?: string | number;
generateValue?: boolean;
fromService?: { type?: string; name?: string; property?: string };
}
interface Service {
type: string;
name: string;
envVars?: EnvVar[];
disk?: { mountPath?: string };
}
interface Blueprint {
projects?: { environments?: { services?: Service[] }[] }[];
services?: Service[];
}
const blueprint = load(blueprintSource) as Blueprint;
/** Services sit under projects → environments, or at the top level. */
const services: Service[] = [
...(blueprint.services ?? []),
...(blueprint.projects ?? []).flatMap((project) =>
(project.environments ?? []).flatMap((environment) => environment.services ?? []),
),
];
/**
* Every key and value in the document, comments excluded by construction
* parsing drops them. The Blueprint documents in comments what it deliberately
* does NOT set, and those comments are what stop the next editor re-adding it,
* so a mention in prose is the documentation and a mention here is the bug.
* Covers shapes the interfaces above don't model (envVarGroups, dockerCommand).
*/
const blueprintData = JSON.stringify(blueprint);
const envVarKeys = services.flatMap((service) => (service.envVars ?? []).map((entry) => entry.key));
const SERVER = 'gitnexus-server';
const WEB = 'gitnexus-web';
/** Looked up per test, so a rename fails each assertion with its own message. */
function serviceNamed(name: string): Service {
const match = services.find((service) => service.name === name);
if (!match) {
throw new Error(
`render.yaml has no service named "${name}". If the rename is intentional, ` +
'update this test — the invariants below are about that service.',
);
}
return match;
}
function envVar(service: Service, key: string): EnvVar | undefined {
return (service.envVars ?? []).find((entry) => entry.key === key);
}
/** Absent as an env var name, and absent anywhere else in the document. */
function expectNeverSet(pattern: RegExp): void {
expect(envVarKeys.filter((key) => pattern.test(key))).toEqual([]);
expect(blueprintData).not.toMatch(pattern);
}
describe('render.yaml ↔ Dockerfile.cli couplings', () => {
it('mounts the disk at GITNEXUS_HOME', () => {
// One key of a multi-line `ENV`.
const home = /^\s*(?:ENV\s+)?GITNEXUS_HOME=(\S+)/m.exec(dockerfileSource)?.[1];
expect(home, 'Dockerfile.cli must set GITNEXUS_HOME').toBeTruthy();
expect(serviceNamed(SERVER).disk?.mountPath).toBe(home);
});
it('routes to the port the image binds', () => {
// ... --port \"${PORT:-4747}\" — the backslashes are literal in the CMD.
const dockerfilePort = /--port\s+\\?"?\$\{PORT:-(\d+)\}/.exec(dockerfileSource)?.[1];
expect(dockerfilePort, "Dockerfile.cli's CMD must bind a default port").toBeTruthy();
const port = envVar(serviceNamed(SERVER), 'PORT')?.value;
expect(port, 'render.yaml must set PORT on gitnexus-server').toBeDefined();
expect(String(port)).toBe(dockerfilePort);
});
});
describe('render.yaml topology', () => {
it('keeps the API server off the internet', () => {
// `web` would give `serve` a public URL, and `serve` has no auth of its own.
expect(serviceNamed(SERVER).type).toBe('pserv');
});
it('never sets GITNEXUS_PUBLIC_ORIGIN', () => {
// `serve` refuses to start with it set and no serve-native auth
// (assertServeAuthForPublicOrigin), and behind the proxy it has no job.
expectNeverSet(/GITNEXUS_PUBLIC_ORIGIN/);
});
it('ships no Azure DevOps credential knob', () => {
// A token holder could POST /api/analyze an Azure URL, spend the operator's
// PAT, then read the private repo back through /api/file and /api/grep.
expectNeverSet(/AZURE_DEVOPS_/);
});
it('points the proxy at the private server over the private network', () => {
expect(envVar(serviceNamed(WEB), 'GITNEXUS_UPSTREAM_URL')?.fromService).toMatchObject({
type: 'pserv',
name: SERVER,
property: 'hostport',
});
});
it('always generates an edge token for the public proxy', () => {
// docker-server.mjs refuses to start with an upstream and no token, so this
// line is what makes the one-click deploy authenticated rather than broken.
expect(envVar(serviceNamed(WEB), 'GITNEXUS_SERVE_AUTH_TOKEN')?.generateValue).toBe(true);
});
it('declares the schema so editors and `render blueprints validate` find it', () => {
expect(blueprintSource.split('\n')[0]).toBe(SCHEMA_PRAGMA);
});
});

81
render.yaml Normal file
View file

@ -0,0 +1,81 @@
# yaml-language-server: $schema=https://render.com/schema/render.yaml.json
#
# One-click GitNexus deploy: a private API server, plus the public web UI that
# proxies /api/* to it over Render's private network.
#
# SECURITY: the web service's URL is discoverable (onrender.com names appear in
# CT logs). The generated GITNEXUS_SERVE_AUTH_TOKEN is the only access control —
# the proxy strips Origin, so the server's CSRF guard sees no proxied traffic,
# and rate limits bound cost, not access. Anyone holding the token can read
# every indexed repo. See SECURITY.md § Hosted Deploys on Render.
previews:
generation: 'off'
projects:
- name: gitnexus
environments:
- name: production
services:
# Private: no public URL. `serve` has no authentication of its own.
- type: pserv
name: gitnexus-server
runtime: docker
# Private networking needs one shared region.
region: oregon
# plan sets RAM: standard 2GB, pro 4GB. Indexing is memory-bound.
plan: standard
dockerfilePath: ./Dockerfile.cli
dockerContext: .
# No dockerCommand — the image's CMD already binds $PORT, and Render
# re-wraps dockerCommand in a shell, so `sh -c` exits 127.
# No healthCheckPath either: Render rejects one on a pserv. The
# proxy's connect-retry covers the restart window instead.
#
# Don't restart an indexing server on a push.
autoDeployTrigger: 'off'
# mountPath must equal GITNEXUS_HOME in Dockerfile.cli.
disk:
name: gitnexus-data
mountPath: /data/gitnexus
sizeGB: 10
envVars:
# Dockerfile.cli's CMD binds this.
- key: PORT
value: 4747
# One hop: gitnexus-web's proxy, which writes X-Forwarded-For
# itself. A count rather than the default ranges (loopback,
# linklocal, uniquelocal), which assume Render's private network
# is RFC1918. `true` is rejected outright.
- key: GITNEXUS_TRUST_PROXY
value: 1
# Public: the UI, plus a token-gated same-origin proxy to the server.
- type: web
name: gitnexus-web
runtime: docker
region: oregon
plan: starter
dockerfilePath: ./Dockerfile.web
dockerContext: .
healthCheckPath: /
autoDeployTrigger: 'off'
envVars:
# host:port, scheme-less; the proxy prefixes http://. Set without
# the token below, docker-server.mjs refuses to start.
- key: GITNEXUS_UPSTREAM_URL
fromService:
type: pserv
name: gitnexus-server
property: hostport
# Bearer token for every /api/* request. Copy it from this
# service's Environment tab into the UI's settings panel; rotate by
# editing it here and redeploying.
- key: GITNEXUS_SERVE_AUTH_TOKEN
generateValue: true
# Render's load balancer is the one hop in front and appends the
# real peer. The proxy ignores inbound XFF by default.
- key: GITNEXUS_PROXY_TRUST_XFF
value: 1
# Deliberately unset: GITNEXUS_BACKEND_URL. docker-server.mjs falls
# back to RENDER_EXTERNAL_URL, this service's own origin.