mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
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
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:
parent
a033b04c46
commit
f2717c6a7c
24 changed files with 2482 additions and 203 deletions
|
|
@ -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}\""]
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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:
|
||||
|
||||
[](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** |
|
||||
|
|
|
|||
13
SECURITY.md
13
SECURITY.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal file
67
gitnexus-web/src/components/AccessTokenPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal file
56
gitnexus-web/src/components/settings/SecretInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}}"
|
||||
|
|
|
|||
|
|
@ -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": "了解更多",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal file
60
gitnexus-web/test/unit/access-token-prompt.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal file
239
gitnexus-web/test/unit/backend-client-auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal file
49
gitnexus-web/test/unit/settings-panel-token.test.tsx
Normal 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('');
|
||||
});
|
||||
});
|
||||
144
gitnexus/test/unit/render-blueprint.test.ts
Normal file
144
gitnexus/test/unit/render-blueprint.test.ts
Normal 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
81
render.yaml
Normal 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.
|
||||
Loading…
Add table
Reference in a new issue