feat(render): private-service topology, docs, and a CI check on render.yaml (#10)

* feat(render): private-service topology, docs, and a CI check on render.yaml

Rewrites the Blueprint around the topology Abhigyan called for: `serve` on a
private service, the public web service reverse-proxying /api/* to it, and the
generated token gating that edge.

`gitnexus-server` becomes a `pserv`, so it has no public URL. `gitnexus-web`
gets `GITNEXUS_UPSTREAM_URL` from `fromService … property: hostport`,
`GITNEXUS_SERVE_AUTH_TOKEN` with `generateValue: true`, and
`GITNEXUS_PROXY_TRUST_XFF: 1` for Render's load balancer. `autoDeploy: true`
becomes `autoDeployTrigger: off` so a push cannot restart an indexing server
mid-analyze.

Four variables come out:

- `GITNEXUS_PUBLIC_ORIGIN`: under the proxy the browser is same-origin with the
  web service and Origin is stripped, so it has no job — and since #2820
  `assertServeAuthForPublicOrigin` refuses to start when it is set without
  serve-native auth. The old topology no longer boots.
- `GITNEXUS_BACKEND_URL`: docker-server.mjs falls back to RENDER_EXTERNAL_URL,
  which is this service's own origin.
- `AZURE_DEVOPS_URL` / `AZURE_DEVOPS_PAT`: per Abhigyan, "a stranger can POST
  /api/analyze an Azure URL, have the server spend the operator's credential,
  then read the private repo back through the ungated /api/file and /api/grep."
  The edge token closes the stranger path, but the knob still should not ship in
  a one-click prompt. Operators who want Azure DevOps set both in the dashboard,
  on a deploy they have already secured.

README drops "with no API keys required" — it described missing authentication
as a convenience, and the deploy now generates a token — and replaces it with
the token-retrieval flow. It also corrects `plan` versus `sizeGB`: `plan` sets
RAM, `sizeGB` only matters when the disk fills. Both the README and a new
SECURITY.md section state plainly that the proxy strips Origin, so the CSRF
guard does nothing for proxied traffic and the token is the only control — not a
second layer behind the guard. SECURITY.md enumerates the routes a token holder
reaches with no origin guard, and notes that `POST /api/mcp` has no `authToken`
of its own as a pre-existing gap in `serve`.

Nothing in CI parsed render.yaml, so the couplings the file documents were
unenforced. `gitnexus/test/unit/render-blueprint.test.ts` asserts them:
mountPath equals GITNEXUS_HOME, PORT matches Dockerfile.cli's CMD, the server
stays a `pserv`, GITNEXUS_PUBLIC_ORIGIN appears in no YAML, the token is
generated, no AZURE_DEVOPS_* key, and the schema pragma is intact. Each was
confirmed to fail when its invariant is deliberately broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(readme): state what the Render deploy costs

The Deploy to Render section described the topology and the token but not
the bill. At the Blueprint's defaults it is ~$35/month: $25 for the
server's `standard` instance, $7 for the web service's `starter`, $2.50
for the 10 GB disk. Neither service scales to zero, which matters more
than the number — an idle deploy costs the same as a busy one.

Also trims the section's em dashes and drops "access token" to "token"
where the antecedent is one sentence away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: update readme

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Shifra Williams 2026-08-05 15:08:20 -07:00 • committed by GitHub
parent a2a2d51193
commit 356032e2f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 219 additions and 38 deletions

View file

@ -86,9 +86,21 @@ Deploy GitNexus in one click:
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/abhigyanpatwari/GitNexus)
Render provisions the API server, a persistent disk for indexes and cloned repos, and the web UI pointed at it, with no API keys required.
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.
Indexing is memory-bound, so if `gitnexus-server` runs out of memory on a large repo, raise its `plan` and `sizeGB`.
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

View file

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

View file

@ -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);
});
});

View file

@ -1,61 +1,62 @@
# yaml-language-server: $schema=https://render.com/schema/render.yaml.json
#
# One-click deploy of GitNexus: an API server and the web UI that calls it.
# fromService gives each the other's public URL, so there is nothing to paste.
# One-click GitNexus deploy: a private API server, plus the public web UI that
# proxies /api/* to it over Render's private network.
#
# SECURITY: this deploy is UNAUTHENTICATED, and onrender.com hostnames appear in
# certificate transparency logs. Anyone who finds the URL can index and delete
# repos. Rate limits bound the cost; they are not access control.
# 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
generation: 'off'
projects:
- name: gitnexus
environments:
- name: production
services:
- type: web
# 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
# Indexing OOMs on the 512MB starter. standard (2GB) covers
# small/mid repos; bump to pro (4GB) or larger for a big one.
# 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 the port below,
# and Render re-wraps dockerCommand in a shell, so `sh -c` exits 127.
healthCheckPath: /api/health
autoDeploy: true
# 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:
# Render routes to this port; Dockerfile.cli's CMD binds 4747.
# Dockerfile.cli's CMD binds this.
- key: PORT
value: 4747
# Required. The container binds 0.0.0.0, which has no host
# identity, so without this the UI's writes fail the same-host
# origin check.
- key: GITNEXUS_PUBLIC_ORIGIN
fromService:
type: web
name: gitnexus-web
envVarKey: RENDER_EXTERNAL_URL
# 1, not true: Render's LB is one hop, and true would trust a
# spoofed X-Forwarded-For and hand the rate limiter a fake IP.
# 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
# Optional Azure DevOps integration; leave unset to skip. Any
# visitor can use this PAT (see SECURITY above).
- key: AZURE_DEVOPS_URL
sync: false
- key: AZURE_DEVOPS_PAT
sync: false
# Deliberately unset: GITNEXUS_PUBLIC_ORIGIN, which has no job
# behind the proxy and makes `serve` refuse to start
# (assertServeAuthForPublicOrigin); AZURE_DEVOPS_URL /
# AZURE_DEVOPS_PAT, since /api/analyze would spend the PAT and
# /api/file could read the private repo back. Set the Azure pair
# in the dashboard if you want it.
# Public: the UI, plus a token-gated same-origin proxy to the server.
- type: web
name: gitnexus-web
runtime: docker
@ -64,12 +65,23 @@ projects:
dockerfilePath: ./Dockerfile.web
dockerContext: .
healthCheckPath: /
autoDeploy: true
autoDeployTrigger: 'off'
envVars:
# Injected into the page as window.__GITNEXUS_CONFIG__.backendUrl,
# so a first-time visitor's UI targets this deploy, not localhost.
- key: GITNEXUS_BACKEND_URL
# 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: web
type: pserv
name: gitnexus-server
envVarKey: RENDER_EXTERNAL_URL
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.