feat(ui): saved trails — a walk you named, kept, and still true after a re-index (CG-60)

Save trail on the trail bar writes the walk to .codegraph/ui/trails/ as one
JSON file, listed on the empty screen and on Entry points above the derived
suggestions, reopened at the symbol you left with the whole path restored.

A hop is stored by qualified name, kind and file — never by node id, which
contains a start line and so changes the first time anybody edits above the
symbol. Every hop is re-resolved against the current index on the way out and
each row says what became of it: still here, moved to another file, now
ambiguous, or gone. A hole is never stitched over: the row opens the longest
run of CONSECUTIVE resolved hops and says which ones those are, because the
trail is a path and a skipped hop would draw a call that does not exist.

This is the first write the viewer makes, and the boundary moved with it:
POST/DELETE answer under /api/ only, must carry X-CodeGraph-UI and
application/json (neither of which a cross-origin form can produce without a
preflight this server answers none of), and --read-only refuses both while
still listing what is there. The blanket "read-only" claim is retired from the
banner, the README, the CLI help and the docs site in favour of the narrower
true one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Colby McHenry
2026-08-27 08:31:48 -05:00
co-authored by Claude Opus 5
parent 55a33055ee
commit 47576b392e
32 changed files with 3162 additions and 78 deletions
+34 -12
View File
@@ -1856,8 +1856,10 @@ function printNoIndexGuidance(projectPath: string): void {
* codegraph ui [path] (alias: web)
*
* The browser reader: serves the built viewer (`dist/viewer/`) over loopback
* and opens it. Read-only in every sense — it answers GET, it opens the index
* for reading, and it never writes to the project or the graph.
* and opens it. It opens the index for reading and never writes to it, never
* indexes, and never changes a line of the project's code. The single thing it
* writes is a trail the reader saved, as JSON under `.codegraph/ui/trails/`;
* `--read-only` turns even that off.
*
* Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own
* banner tells the user nothing leaves their machine, so it must not be the
@@ -1870,6 +1872,7 @@ program
.description('Open the CodeGraph viewer in your browser — read your indexed project as a graph')
.option('--port <number>', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`)
.option('--no-open', 'Print the URL instead of opening a browser')
.option('--read-only', 'Refuse every write — saved trails can be opened but not saved or deleted')
.addHelpText(
'after',
`
@@ -1897,10 +1900,17 @@ The page keeps up with the project while it is open: save a file and it says so
within about a third of a second, and whatever is on screen re-reads the graph
when something re-indexes it. It watches for that; it never polls.
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it,
and it is read-only: it opens an index that already exists and never changes
your project or your graph. Requests from any other host are refused, and
nothing is sent anywhere: no code, no paths, no analytics.
Save a walk you want to keep: name the trail and it is written to
.codegraph/ui/trails/ (already gitignored) as plain JSON, listed on the empty
screen, and reopened at the symbol you left. Hops are remembered by name rather
than by position, so a saved trail survives re-indexing and says which hop moved
when one does. Pass --read-only to refuse every write.
The viewer listens on 127.0.0.1 only, so nothing on your network can reach it.
It opens an index that already exists, never indexes, and never changes a line
of your code — the one thing it writes is a trail you asked it to save.
Requests from any other host are refused, and nothing is sent anywhere: no code,
no paths, no analytics.
Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy.
@@ -1908,7 +1918,7 @@ Set ${BROWSER_ENV}=<command> to choose which browser opens, or
${BROWSER_ENV}=none to never open one.
`
)
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => {
.action(async (pathArg: string | undefined, options: { port?: string; open?: boolean; readOnly?: boolean }) => {
// An explicit --port stays explicit: a scripted `--port 8080` that quietly
// lands on 8081 is worse than one that says the port is busy. The default
// port is the only one we're free to walk away from.
@@ -1942,10 +1952,17 @@ ${BROWSER_ENV}=none to never open one.
'../ui-server'
);
// The read-only JSON API the viewer reads its screens from. It opens the
// index lazily on the first request, so a slow first paint is the only cost
// of mounting it here rather than after the browser connects.
const api = createGraphApi({ projectRoot: projectPath });
// The JSON API the viewer reads its screens from. It opens the index lazily
// on the first request, so a slow first paint is the only cost of mounting
// it here rather than after the browser connects.
const readOnly = options.readOnly === true;
const api = createGraphApi({
projectRoot: projectPath,
readOnly,
readOnlyReason: readOnly
? 'This viewer was started with --read-only, so trails cannot be saved.'
: undefined,
});
let handle: UiServerHandle;
try {
@@ -1968,7 +1985,12 @@ ${BROWSER_ENV}=none to never open one.
console.log('');
console.log(` ${chalk.dim('Reading')} ${projectPath}`);
console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`);
console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`);
console.log(
` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} ` +
(readOnly
? 'read-only, nothing leaves your computer'
: 'nothing leaves your computer; saved trails are the only thing written')
);
console.log('');
const opened = options.open === false ? false : openBrowser(handle.url);
+15
View File
@@ -1373,6 +1373,21 @@ export class CodeGraph {
return this.queries.getNodesByIds(ids);
}
/**
* Every symbol carrying an exact qualified name.
*
* The identity that survives a re-index. A node's id contains its start line,
* so any edit ABOVE a symbol gives it a different id — anything that has to
* name the same symbol across two indexes (a saved trail, a bookmark, a
* review comment) has to key on this instead, and then disambiguate the
* result by kind and file. Index-backed; unlike
* {@link GraphQueryManager.findByQualifiedName} it takes no pattern and scans
* nothing.
*/
getNodesByQualifiedName(qualifiedName: string): Node[] {
return this.queries.getNodesByQualifiedNameExact(qualifiedName);
}
/**
* Outgoing edges for many source nodes at once — the batch form of
* {@link getOutgoingEdges}. See {@link QueryBuilder.getOutgoingEdgesFrom}.
+124 -12
View File
@@ -1,12 +1,17 @@
/**
* The read-only JSON API the viewer reads its screens from.
*
* Twelve endpoints, one per screen, each answering in a single round-trip — the
* same principle as `codegraph_explore`: return enough that the caller does not
* have to ask a follow-up question — plus one that does not answer at all and
* stays open instead (`/api/events`), so a screen learns that its answer went
* stale rather than waiting to be asked again. Everything here is a *reader* of
* the existing schema; nothing indexes, resolves, or writes.
* Thirteen endpoints, one per screen, each answering in a single round-trip —
* the same principle as `codegraph_explore`: return enough that the caller does
* not have to ask a follow-up question — plus one that does not answer at all
* and stays open instead (`/api/events`), so a screen learns that its answer
* went stale rather than waiting to be asked again.
*
* All but one are *readers* of the existing schema; nothing here indexes or
* resolves. The exception is `/api/trails`, which saves the reader's own named
* walks as JSON under `.codegraph/ui/trails/` — the only write the viewer makes,
* to the only directory it may write to, and refused outright under
* `--read-only`. See `./trail-store.ts`.
*
* ```
* GET /api/stats what this index is and how much to trust it
@@ -22,20 +27,25 @@
* GET /api/deadcode symbols nothing reaches, and what was excluded
* GET /api/flow?from=&to= the flow strip: one card per hop
* GET /api/events the live channel (SSE): drift and refresh
* GET /api/trails saved trails, re-resolved against the index
* POST /api/trails save one (refused under --read-only)
* DELETE /api/trails/<id> remove one (refused under --read-only)
* ```
*
* It mounts on the `api` seam of `startUiServer`, which means it sits *behind*
* the loopback boundary in `security.ts`: the `Host` allowlist, the absence of
* CORS headers and the GET/HEAD restriction are already enforced by the time a
* handler here runs. The one obligation that remains ours is the read
* chokepoint `resolveProjectFile` for anything that touches the repository —
* and it lives in `source.ts`, the only module here that opens a file.
* CORS headers and the method restriction are already enforced by the time a
* handler here runs — including the extra shape a write has to have. The one
* obligation that remains ours is the path chokepoint, `resolveProjectFile` for
* anything that touches the repository. Two modules here reach the filesystem
* and no others: `source.ts` reads the project's code, and `trail-store.ts`
* reads and writes `.codegraph/ui/trails/`.
*/
import type { UiApiHandler, UiRequestContext } from '../index';
import { PathRefusalError } from '../security';
import { GraphSession } from './session';
import { ApiError, badRequest, fail, notFound, ok } from './respond';
import { ApiError, badRequest, fail, notFound, ok, readJsonBody } from './respond';
import { buildStats } from './stats';
import { buildSearch } from './search';
import { buildNode } from './node';
@@ -48,6 +58,7 @@ import { buildNodeRefs } from './nodes';
import { buildMap } from './map';
import { buildDeadCode } from './deadcode';
import { buildFlow } from './flow';
import { buildTrails, removeTrail, saveTrail, type TrailsOptions } from './trails';
import { EventHub } from './events';
export { GraphSession } from './session';
@@ -99,6 +110,28 @@ export type {
WireDeadCodeRow,
} from './deadcode';
export { MAX_DEAD_CODE_MEMBERS, MAX_DEAD_CODE_ROWS } from './deadcode';
export type {
WireTrail,
WireTrailHop,
WireTrailHopStatus,
WireTrails,
SaveTrailRequest,
TrailsOptions,
} from './trails';
export { buildTrails, encodeResolvedRun, resolveHop, resolveTrail } from './trails';
export type { StoredHop, StoredTrail } from './trail-store';
export {
MAX_TRAILS,
MAX_TRAIL_HOPS,
MAX_TRAIL_NAME,
MAX_TRAIL_NOTE,
TRAILS_RELATIVE_DIR,
TRAIL_FORMAT_VERSION,
isTrailId,
listStoredTrails,
parseTrail,
slugify,
} from './trail-store';
/**
* A mounted API, plus the handle it holds open.
@@ -114,12 +147,29 @@ export interface GraphApi {
export interface GraphApiOptions {
/** Absolute path of the indexed project to read. */
projectRoot: string;
/**
* Refuse every write, so the viewer is a pure reader again.
*
* The one thing it would otherwise write is a saved trail into
* `.codegraph/ui/trails/`. Turning this on is for a checkout that must not
* change (a review sandbox, a read-only mount, a shared machine); the viewer
* still lists trails that are already there, and says why Save is gone.
*/
readOnly?: boolean;
/** The sentence shown in place of Save. Defaults to a generic one. */
readOnlyReason?: string;
}
/** What `GET /api` answers: the endpoint list, for anyone poking at it by hand. */
const API_INDEX = {
name: 'codegraph ui',
readOnly: true,
/**
* Every endpoint but `/api/trails` is a pure read. Kept as a field rather
* than dropped, because it was `true` and something may be reading it; it is
* now the honest, narrower claim.
*/
readOnly: false,
writes: ['POST /api/trails', 'DELETE /api/trails/<id>'],
endpoints: [
{ path: '/api/stats', description: 'Index state, graph counts, detected frameworks.' },
{ path: '/api/search', description: 'Ranked symbol search.', params: ['q', 'limit'] },
@@ -167,6 +217,12 @@ const API_INDEX = {
description: 'Where to start reading: routes, files that run something, and hubs.',
params: ['limit'],
},
{
path: '/api/trails',
description:
'Saved trails, each hop re-resolved against the current index. POST saves one, ' +
'DELETE /api/trails/<id> removes it. The only endpoint that writes.',
},
],
};
@@ -175,12 +231,25 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
// Watches nothing until a browser subscribes, and stops again when the last
// one goes away — mounting the API costs no watch descriptors.
const events = new EventHub(options.projectRoot, session);
const trails: TrailsOptions = {
readOnly: options.readOnly === true,
readOnlyReason:
options.readOnly === true
? options.readOnlyReason ?? 'This viewer is running read-only, so trails cannot be saved.'
: null,
};
// Async because `/api/source` highlights: everything else answers straight
// out of SQLite and resolves on the same tick.
const handler: UiApiHandler = async (req, res, ctx) => {
const route = normalize(ctx.pathname);
try {
// Writes first: they are the only requests that carry a body, and
// routing them beside the readers would put a `case` that mutates in a
// switch every other arm of which is a query.
if (ctx.method === 'POST' || ctx.method === 'DELETE') {
return await dispatchWrite(route, req, res, ctx, session, trails);
}
switch (route) {
case '/api':
return ok(res, API_INDEX, ctx.method);
@@ -196,6 +265,8 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
return ok(res, buildDeadCode(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
case '/api/entrypoints':
return ok(res, buildEntryPoints(session.acquire(), ctx.query), ctx.method);
case '/api/trails':
return ok(res, buildTrails(session.acquire(), ctx.projectRoot, trails), ctx.method);
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
@@ -231,6 +302,47 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
};
}
/**
* The write half: `/api/trails` and nothing else.
*
* Kept to one function so the answer to "what can this server change?" is one
* place a reviewer can read in full. Anything else that arrives with a write
* method is a 405 naming the endpoint that does accept one — by the time this
* runs, `isWriteRequest` has already established the request could not have
* been forged from another origin, so an unhelpfully vague refusal here would
* only confuse the person poking at their own API.
*/
async function dispatchWrite(
route: string,
req: Parameters<UiApiHandler>[0],
res: Parameters<UiApiHandler>[1],
ctx: UiRequestContext,
session: GraphSession,
trails: TrailsOptions
): Promise<boolean> {
if (ctx.method === 'POST' && route === '/api/trails') {
const body = await readJsonBody(req);
return ok(res, saveTrail(session.acquire(), ctx.projectRoot, body, trails), ctx.method);
}
if (ctx.method === 'DELETE') {
const id = suffixAfter(route, '/api/trails/');
if (id !== null && id !== '') {
return ok(res, removeTrail(session.acquire(), ctx.projectRoot, id, trails), ctx.method);
}
if (route === '/api/trails') {
throw badRequest('Deleting a trail needs its id: DELETE /api/trails/<id>.');
}
}
res.setHeader('Allow', 'GET, HEAD');
throw new ApiError(
'bad-request',
`${ctx.method} ${route} is not something this server changes.`,
'The only endpoint that writes is /api/trails (POST to save, DELETE /api/trails/<id> to remove).'
);
}
/**
* The two endpoints that carry their argument in the path.
*
+49 -1
View File
@@ -8,7 +8,7 @@
* the CLI and the MCP tools do. What it never does is leak a stack trace.
*/
import type { ServerResponse } from 'http';
import type { IncomingMessage, ServerResponse } from 'http';
import { sendJson } from '../static';
/**
@@ -88,6 +88,54 @@ export function fail(res: ServerResponse, err: unknown, method: string): true {
return true;
}
// =============================================================================
// Request bodies
// =============================================================================
/**
* Bytes a request body may carry.
*
* The only body this server reads is a saved trail: a name and up to 64 node
* ids. 64 KB is generous for that and small enough that a runaway client cannot
* make the process hold a megabyte per socket.
*/
export const MAX_BODY_BYTES = 64 * 1024;
/**
* Read a request body as JSON.
*
* Counts BYTES, not characters, and stops at the cap by destroying the socket
* rather than draining a body nobody is going to parse — a `Content-Length`
* header is a claim, and the only limit that holds is the one applied to what
* actually arrives.
*
* @throws {ApiError} `bad-request` for a body that is too large or is not JSON.
*/
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
try {
for await (const chunk of req) {
const buf = chunk as Buffer;
size += buf.length;
if (size > MAX_BODY_BYTES) {
req.destroy();
throw badRequest(`That request body is too large (max ${MAX_BODY_BYTES} bytes).`);
}
chunks.push(buf);
}
} catch (err) {
if (err instanceof ApiError) throw err;
throw badRequest('That request body could not be read.');
}
if (size === 0) throw badRequest('That request needs a JSON body.');
try {
return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
} catch {
throw badRequest('That request body is not valid JSON.');
}
}
// =============================================================================
// Query parameters
// =============================================================================
+332
View File
@@ -0,0 +1,332 @@
/**
* Where saved trails live on disk — the only thing `codegraph ui` ever writes.
*
* Every other module under `api/` is a reader. This one holds the single write
* path in the whole viewer, and it is scoped as narrowly as a write can be: one
* directory, `<CODEGRAPH_DIR>/ui/trails/`, inside the project the server was
* started on, one JSON file per trail. It never touches source, never touches
* the index, and never writes anywhere a `codegraph init` would not already
* have created. `.codegraph/.gitignore` ignores everything but itself, so a
* saved trail is local by default; exporting one to commit is a copy the reader
* makes deliberately.
*
* Two rules hold it inside the boundary described in `../security.ts`:
*
* - **The directory is resolved through `resolveProjectFile`**, exactly like a
* source read, so a trail id that tried to be a path is refused by the same
* chokepoint that refuses `?file=../../.ssh/id_rsa`. It is belt and braces on
* top of {@link isTrailId}, which already refuses anything but a slug.
* - **A write is atomic.** Temp file in the same directory, then rename. A
* half-written trail read back by the list would look like a corrupt one, and
* the list would then have to decide whether to hide it — which is a decision
* nobody should have to make about a file they saved a second ago.
*
* The format is deliberately plain: a reader can open one in an editor, and a
* hop is described by what it IS (a qualified name in a file) rather than by the
* node id it happened to have. Node ids contain a start line, so any edit above
* a symbol renames it — a trail keyed on ids would not survive its own project.
*/
import * as fs from 'fs';
import * as path from 'path';
import { CODEGRAPH_DIR } from '../../directory';
import { resolveProjectFile } from '../security';
import { ApiError, badRequest } from './respond';
/** Where trails live, relative to the project root. Forward slashes always. */
export const TRAILS_RELATIVE_DIR = `${CODEGRAPH_DIR}/ui/trails`;
/** The only `version` this build writes, and the only one it reads. */
export const TRAIL_FORMAT_VERSION = 1;
/** Trail files read from the directory before the list stops looking. */
export const MAX_TRAILS = 200;
/** Hops one trail may carry. Past this it is a history, not a tour. */
export const MAX_TRAIL_HOPS = 64;
/** Characters in a trail's name. */
export const MAX_TRAIL_NAME = 120;
/** Characters in a trail's note. */
export const MAX_TRAIL_NOTE = 600;
/** Bytes a single trail file may be before it is skipped as not-ours. */
export const MAX_TRAIL_FILE_BYTES = 64 * 1024;
/** Characters in a generated slug, before any de-duplicating suffix. */
const MAX_SLUG = 60;
/** How a reader got from the previous hop to this one. Mirrors the viewer's `HopDirection`. */
export type StoredHopDirection = 'start' | 'down' | 'up';
/**
* One hop, described by what it is rather than by the id it had.
*
* `id` is kept as a HINT — when the file has not changed it resolves in one
* lookup — but `qualifiedName` + `kind` + `file` is what the trail is actually
* keyed on, and what lets it survive a re-index.
*/
export interface StoredHop {
dir: StoredHopDirection;
name: string;
qualifiedName: string;
kind: string;
/** Project-relative, forward slashes. */
file: string;
line: number;
/** The node id at save time. A fast path, never the identity. */
id: string;
}
export interface StoredTrail {
version: number;
/** Slug, and the file's basename. */
id: string;
name: string;
note: string;
/** Whoever saved it — git's `user.name`, or the OS user. */
author: string;
createdAt: string;
updatedAt: string;
hops: StoredHop[];
}
/* ------------------------------------------------------------------ paths -- */
/**
* Whether a string is a trail id we would have written.
*
* Lowercase slug characters only: no dot, no separator, no leading dash. This
* is what makes `<id>.json` a filename rather than a path expression, and it
* runs before the id is ever joined to anything.
*/
export function isTrailId(value: string): boolean {
return /^[a-z0-9][a-z0-9-]{0,79}$/.test(value);
}
/**
* `Read a file with these lines` -> `read-a-file-with-these-lines`.
*
* Names that carry no ASCII letters or digits at all (a trail named entirely in
* Chinese, or in emoji) slug to nothing; they get `trail`, and the collision
* handling in {@link saveTrail} keeps them distinct from each other.
*/
export function slugify(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG)
.replace(/-+$/g, '');
return slug === '' ? 'trail' : slug;
}
/** The absolute trails directory, having been through the read chokepoint. */
export function trailsDirectory(projectRoot: string): string {
return resolveProjectFile(projectRoot, TRAILS_RELATIVE_DIR);
}
/**
* The absolute path of one trail file.
*
* @throws {ApiError} `bad-request` when the id is not a slug we would have
* written — checked before the join, so nothing path-shaped is ever built.
*/
export function trailPath(projectRoot: string, id: string): string {
if (!isTrailId(id)) {
throw badRequest(
`"${id}" is not a saved trail id.`,
'Trail ids are the lowercase slug in the file name, e.g. "how-a-request-is-served".'
);
}
return resolveProjectFile(projectRoot, `${TRAILS_RELATIVE_DIR}/${id}.json`);
}
/* ------------------------------------------------------------------- read -- */
/**
* Parse a file into a trail, or `null` if it is not one.
*
* Everything is re-validated rather than trusted: the directory is a place a
* user may hand-edit a file, or drop one somebody else exported, and a trail
* that half-parsed would draw a row with holes in it. A file that fails is
* skipped and counted, never repaired in place.
*/
export function parseTrail(id: string, text: string): StoredTrail | null {
let raw: unknown;
try {
raw = JSON.parse(text);
} catch {
return null;
}
if (typeof raw !== 'object' || raw === null) return null;
const value = raw as Record<string, unknown>;
if (typeof value.name !== 'string' || value.name.trim() === '') return null;
if (!Array.isArray(value.hops) || value.hops.length === 0) return null;
const hops: StoredHop[] = [];
for (const entry of value.hops.slice(0, MAX_TRAIL_HOPS)) {
if (typeof entry !== 'object' || entry === null) return null;
const hop = entry as Record<string, unknown>;
const qualifiedName = typeof hop.qualifiedName === 'string' ? hop.qualifiedName : '';
const name = typeof hop.name === 'string' ? hop.name : '';
if (qualifiedName === '' && name === '') return null;
hops.push({
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
name: name || qualifiedName,
qualifiedName: qualifiedName || name,
kind: typeof hop.kind === 'string' ? hop.kind : '',
file: typeof hop.file === 'string' ? hop.file : '',
line: typeof hop.line === 'number' && hop.line > 0 ? Math.floor(hop.line) : 0,
id: typeof hop.id === 'string' ? hop.id : '',
});
}
const created = typeof value.createdAt === 'string' ? value.createdAt : '';
return {
version: typeof value.version === 'number' ? value.version : TRAIL_FORMAT_VERSION,
// The FILE's name wins over any `id` inside it: the basename is what the
// delete route addresses, so a hand-copied file is addressable under the
// name it actually has rather than the one it remembers having.
id,
name: value.name.slice(0, MAX_TRAIL_NAME),
note: typeof value.note === 'string' ? value.note.slice(0, MAX_TRAIL_NOTE) : '',
author: typeof value.author === 'string' ? value.author.slice(0, 120) : '',
createdAt: created,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : created,
hops,
};
}
export interface StoredTrailList {
trails: StoredTrail[];
/** Files in the directory that were not readable trails. */
skipped: number;
}
/**
* Every trail in the project, newest save first.
*
* A missing directory is the ordinary state of a project nobody has saved a
* trail in — an empty list, never an error.
*/
export function listStoredTrails(projectRoot: string): StoredTrailList {
const dir = trailsDirectory(projectRoot);
let names: string[];
try {
names = fs.readdirSync(dir);
} catch {
return { trails: [], skipped: 0 };
}
const trails: StoredTrail[] = [];
let skipped = 0;
for (const name of names.sort()) {
if (!name.endsWith('.json')) continue;
if (trails.length >= MAX_TRAILS) break;
const id = name.slice(0, -'.json'.length);
if (!isTrailId(id)) {
skipped += 1;
continue;
}
const trail = readTrailFile(path.join(dir, name), id);
if (trail) trails.push(trail);
else skipped += 1;
}
// Newest save first: a tour written a minute ago is the one being iterated on.
trails.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : a.name.localeCompare(b.name)));
return { trails, skipped };
}
function readTrailFile(absolute: string, id: string): StoredTrail | null {
try {
const stat = fs.statSync(absolute);
// A file too big to be a trail is skipped rather than read: this directory
// is inside the project, and something else may one day put a log in it.
if (!stat.isFile() || stat.size > MAX_TRAIL_FILE_BYTES) return null;
return parseTrail(id, fs.readFileSync(absolute, 'utf-8'));
} catch {
return null;
}
}
/** One trail by id, or `null` when there is no such file. */
export function readStoredTrail(projectRoot: string, id: string): StoredTrail | null {
return readTrailFile(trailPath(projectRoot, id), id);
}
/* ------------------------------------------------------------------ write -- */
/**
* Write a trail, atomically.
*
* Temp file beside the target then `rename`, so a reader either sees the
* previous trail or the new one and never a partial file. The temp name carries
* the pid: two `codegraph ui` processes on one project is unusual but not
* forbidden, and two writers sharing a temp name would corrupt each other's.
*/
export function writeStoredTrail(projectRoot: string, trail: StoredTrail): void {
const dir = trailsDirectory(projectRoot);
try {
fs.mkdirSync(dir, { recursive: true });
} catch (err) {
throw writeFailure(err);
}
const target = trailPath(projectRoot, trail.id);
const temp = `${target}.${process.pid}.tmp`;
try {
fs.writeFileSync(temp, `${JSON.stringify(trail, null, 2)}\n`, 'utf-8');
fs.renameSync(temp, target);
} catch (err) {
try {
fs.unlinkSync(temp);
} catch {
// Nothing to clean up, or nothing we can do about it. The write already
// failed; the caller is about to be told so.
}
throw writeFailure(err);
}
}
/** Remove a trail. Returns false when there was nothing there. */
export function deleteStoredTrail(projectRoot: string, id: string): boolean {
try {
fs.unlinkSync(trailPath(projectRoot, id));
return true;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false;
throw writeFailure(err);
}
}
/**
* An id nothing in `taken` is using, preferring the plain slug.
*
* A save under a name that is already there REPLACES it — that is what a reader
* pressing Save with the same name means — so the caller passes the ids of
* trails carrying a *different* name, and this only steps aside for those.
*/
export function uniqueTrailId(base: string, taken: ReadonlySet<string>): string {
if (!taken.has(base)) return base;
for (let n = 2; n < 1000; n += 1) {
const candidate = `${base}-${n}`;
if (!taken.has(candidate)) return candidate;
}
// 999 trails sharing one slug is not a state worth a clever answer.
throw new ApiError('bad-request', `Too many saved trails are already named like "${base}".`);
}
function writeFailure(err: unknown): ApiError {
const code = (err as NodeJS.ErrnoException).code;
const detail = err instanceof Error ? err.message : String(err);
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
return new ApiError(
'refused',
`Saved trails could not be written: ${detail}`,
`The viewer writes only to ${TRAILS_RELATIVE_DIR} inside this project. Check that it is writable.`
);
}
return new ApiError('internal', `Saved trails could not be written: ${detail}`);
}
+477
View File
@@ -0,0 +1,477 @@
/**
* `GET/POST/DELETE /api/trails` — saved trails, the reader's own tours through
* the graph (design spec §3.12).
*
* A trail is the path of symbols someone walked to explain something: "how a
* request is served", "everything the token expiry touches". The viewer already
* carries one in the URL; this is the same walk given a name and kept, so the
* next person — or the same person next week — starts at the explanation rather
* than at the search box.
*
* ## The one thing this feature has to get right
*
* **A trail must survive a re-index.** A node's id contains its start line, so
* inserting an import at the top of a file renames every symbol below it. A
* trail keyed on ids would break the first time anybody edited the code it
* describes — which is exactly when it matters. So a hop is stored as what it
* *is* — qualified name, kind, file — with the id kept only as a fast path, and
* every hop is re-resolved against the current index on the way out:
*
* - the recorded id still names the same symbol → `ok`
* - the qualified name resolves somewhere else → `moved`, and the row says
* where from
* - the name is now carried by several symbols and none is in the recorded
* file → `ambiguous`, best guess offered and labelled as one
* - nothing answers to it → `missing`, and the row says "moved or renamed"
*
* Nothing is silently dropped and nothing is silently guessed: a trail that has
* decayed says so on its own row, which is the point at which its author can
* fix it.
*
* ## What it opens
*
* A trail with a hole in it cannot be handed to the viewer whole — the `t`
* param is a PATH, and stitching hop 2 to hop 4 would draw an adjacency that
* does not exist. So the payload carries the longest run of consecutive
* resolved hops, and the row says when that is less than the whole trail.
*
* Storage — the only write `codegraph ui` makes — is `./trail-store.ts`.
*/
import { execFileSync } from 'child_process';
import * as os from 'os';
import type { CodeGraph } from '../../index';
import type { Node } from '../../types';
import { ApiError, badRequest, notFound } from './respond';
import {
MAX_TRAIL_HOPS,
MAX_TRAIL_NAME,
MAX_TRAIL_NOTE,
MAX_TRAILS,
TRAILS_RELATIVE_DIR,
TRAIL_FORMAT_VERSION,
deleteStoredTrail,
listStoredTrails,
slugify,
uniqueTrailId,
writeStoredTrail,
type StoredHop,
type StoredHopDirection,
type StoredTrail,
} from './trail-store';
import { toNodeRef } from './wire';
/* ------------------------------------------------------------------ wire -- */
/** How a saved hop fared against the current index. */
export type WireTrailHopStatus = 'ok' | 'moved' | 'ambiguous' | 'missing';
export interface WireTrailHop {
dir: StoredHopDirection;
/** The name as it was when the trail was saved. */
name: string;
qualifiedName: string;
kind: string;
/** Where the symbol was when the trail was saved. */
savedFile: string;
savedLine: number;
status: WireTrailHopStatus;
/** The symbol's id NOW. Null when nothing answers to it any more. */
id: string | null;
file: string | null;
line: number | null;
/** Finished screen wording for a status that is not `ok`; null when it is. */
note: string | null;
}
export interface WireTrail {
id: string;
name: string;
note: string;
author: string;
createdAt: string;
updatedAt: string;
hops: WireTrailHop[];
/** Hops that still resolve to a symbol in this index. */
resolved: number;
/** Every hop resolved, and none of them moved. */
intact: boolean;
/**
* The longest run of CONSECUTIVE resolved hops, encoded as the `t` param.
* Null when nothing in the trail resolves. Never stitched across a hole: the
* trail is a path, and a fabricated adjacency is worse than a short one.
*/
encoded: string | null;
/** 1-based index of the first hop `encoded` carries. */
openFrom: number;
/** How many hops `encoded` carries. */
openCount: number;
/** The symbol the trail opens at — the last hop of that run. */
openId: string | null;
}
export interface WireTrails {
trails: WireTrail[];
/** Writes are off. The viewer hides Save and Delete, and says why. */
readOnly: boolean;
readOnlyReason: string | null;
/** Project-relative directory the files live in. The screen names it. */
directory: string;
/** Files in that directory that were not readable trails. */
skipped: number;
/** The list stopped at {@link MAX_TRAILS}. */
bounded: boolean;
/** The id just written, on the answer to a POST. */
saved?: string;
/** That POST replaced a trail of the same name. */
replaced?: boolean;
/** The id just removed, on the answer to a DELETE. */
deleted?: string;
}
/* -------------------------------------------------------------- resolution -- */
/**
* Re-resolve one saved hop against the index as it is now.
*
* Order matters: the recorded id first, because in the common case (nothing
* above the symbol changed) it is one lookup and exactly right. It is still
* verified against the qualified name — an id is a hash of position as well as
* identity, and a recycled one pointing at a different symbol would put a
* stranger in the middle of somebody's explanation.
*/
export function resolveHop(cg: CodeGraph, hop: StoredHop): WireTrailHop {
const base = {
dir: hop.dir,
name: hop.name,
qualifiedName: hop.qualifiedName,
kind: hop.kind,
savedFile: hop.file,
savedLine: hop.line,
};
const byId = hop.id ? cg.getNode(hop.id) : null;
if (byId && matches(byId, hop)) {
return { ...base, status: 'ok', id: byId.id, file: byId.filePath, line: byId.startLine, note: null };
}
const candidates = cg
.getNodesByQualifiedName(hop.qualifiedName)
.filter((node) => hop.kind === '' || node.kind === hop.kind);
if (candidates.length === 0) {
return {
...base,
status: 'missing',
id: null,
file: null,
line: null,
note: `no longer in the index — moved or renamed since this trail was saved`,
};
}
const sameFile = candidates.filter((node) => node.filePath === hop.file);
if (sameFile.length === 1) {
const node = sameFile[0] as Node;
return { ...base, status: 'ok', id: node.id, file: node.filePath, line: node.startLine, note: null };
}
if (candidates.length === 1) {
const node = candidates[0] as Node;
return {
...base,
status: 'moved',
id: node.id,
file: node.filePath,
line: node.startLine,
note: `moved from ${hop.file || 'an unrecorded file'} to ${node.filePath}`,
};
}
// Several symbols carry this name and none of them is where it used to be.
// The best guess is offered — a row nobody can open is not more honest, it
// is just less useful — but it is labelled as a guess.
const pick = (sameFile[0] ?? candidates[0]) as Node;
return {
...base,
status: 'ambiguous',
id: pick.id,
file: pick.filePath,
line: pick.startLine,
note: `${candidates.length} symbols now carry this name — showing the one in ${pick.filePath}`,
};
}
function matches(node: Node, hop: StoredHop): boolean {
if (hop.kind !== '' && node.kind !== hop.kind) return false;
return node.qualifiedName === hop.qualifiedName || node.name === hop.name;
}
/** The `t` param's own encoding — kept identical to `ui/src/lib/trail-codec.ts`. */
const DIR_CHAR: Record<StoredHopDirection, string> = { start: 's', down: 'd', up: 'u' };
/**
* Turn resolved hops into something the viewer can open.
*
* The longest CONSECUTIVE run, not every resolved hop: skipping a missing hop
* would encode a step from A to C that no edge supports, and the Flow strip
* reads a trail as exactly that sequence of edges. The first hop of the run is
* always written as `start`, because a run beginning mid-trail arrived from
* nothing the viewer can draw.
*/
export function encodeResolvedRun(hops: readonly WireTrailHop[]): {
encoded: string | null;
openFrom: number;
openCount: number;
openId: string | null;
} {
let bestStart = -1;
let bestLength = 0;
let start = -1;
for (let i = 0; i <= hops.length; i += 1) {
const resolved = i < hops.length && (hops[i] as WireTrailHop).id !== null;
if (resolved) {
if (start < 0) start = i;
continue;
}
if (start >= 0 && i - start > bestLength) {
bestStart = start;
bestLength = i - start;
}
start = -1;
}
if (bestLength === 0) return { encoded: null, openFrom: 0, openCount: 0, openId: null };
const run = hops.slice(bestStart, bestStart + bestLength);
const encoded = run
.map((hop, index) => `${index === 0 ? 's' : DIR_CHAR[hop.dir]}${encodeURIComponent(hop.id as string)}`)
.join(',');
return {
encoded,
openFrom: bestStart + 1,
openCount: bestLength,
openId: (run[run.length - 1] as WireTrailHop).id,
};
}
export function resolveTrail(cg: CodeGraph, stored: StoredTrail): WireTrail {
const hops = stored.hops.map((hop) => resolveHop(cg, hop));
const run = encodeResolvedRun(hops);
return {
id: stored.id,
name: stored.name,
note: stored.note,
author: stored.author,
createdAt: stored.createdAt,
updatedAt: stored.updatedAt,
hops,
resolved: hops.filter((hop) => hop.id !== null).length,
intact: hops.every((hop) => hop.status === 'ok'),
...run,
};
}
/* ------------------------------------------------------------------ read -- */
export interface TrailsOptions {
/** Writes refused, and the sentence saying why. */
readOnly: boolean;
readOnlyReason: string | null;
}
export function buildTrails(
cg: CodeGraph,
projectRoot: string,
options: TrailsOptions
): WireTrails {
const { trails, skipped } = listStoredTrails(projectRoot);
return {
trails: trails.map((stored) => resolveTrail(cg, stored)),
readOnly: options.readOnly,
readOnlyReason: options.readOnlyReason,
directory: TRAILS_RELATIVE_DIR,
skipped,
bounded: trails.length >= MAX_TRAILS,
};
}
/* ----------------------------------------------------------------- write -- */
/** What a POST body has to be. Everything else about a hop comes from the graph. */
export interface SaveTrailRequest {
name: string;
note?: string;
hops: Array<{ dir?: string; id: string }>;
}
/**
* Save a trail.
*
* The client sends ids and directions and nothing else: the name, kind, file
* and line of every hop are read out of the index here. A client that supplied
* its own metadata could save a trail describing symbols that are not in the
* graph, and the whole value of the feature is that a trail is a claim the
* index can re-check.
*
* A save under a name that already exists REPLACES that trail, keeping its
* `createdAt`. That is what pressing Save with the same name means, and the
* answer says `replaced` so the screen can too.
*/
export function saveTrail(
cg: CodeGraph,
projectRoot: string,
body: unknown,
options: TrailsOptions
): WireTrails {
if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
const request = parseSaveRequest(body);
const hops: StoredHop[] = [];
request.hops.forEach((hop, index) => {
const node = cg.getNode(hop.id);
if (!node) {
throw badRequest(
`Hop ${index + 1} is not in the index: ${hop.id}`,
'Trails are saved from symbols the index holds. Reload the page and walk the trail again.'
);
}
const ref = toNodeRef(node);
hops.push({
dir: hop.dir === 'up' || hop.dir === 'down' ? hop.dir : 'start',
name: ref.name,
qualifiedName: ref.qualifiedName,
kind: ref.kind,
file: ref.file,
line: ref.line,
id: ref.id,
});
});
// The first hop is where the walk began, whatever the client called it.
if (hops[0]) hops[0].dir = 'start';
const existing = listStoredTrails(projectRoot).trails;
const sameName = existing.find((trail) => trail.name === request.name);
const takenByOthers = new Set(
existing.filter((trail) => trail.name !== request.name).map((trail) => trail.id)
);
const id = sameName ? sameName.id : uniqueTrailId(slugify(request.name), takenByOthers);
const now = new Date().toISOString();
writeStoredTrail(projectRoot, {
version: TRAIL_FORMAT_VERSION,
id,
name: request.name,
note: request.note,
author: trailAuthor(projectRoot),
createdAt: sameName?.createdAt || now,
updatedAt: now,
hops,
});
return { ...buildTrails(cg, projectRoot, options), saved: id, replaced: sameName !== undefined };
}
export function removeTrail(
cg: CodeGraph,
projectRoot: string,
id: string,
options: TrailsOptions
): WireTrails {
if (options.readOnly) throw readOnlyRefusal(options.readOnlyReason);
if (!deleteStoredTrail(projectRoot, id)) {
throw notFound(`There is no saved trail called "${id}".`);
}
return { ...buildTrails(cg, projectRoot, options), deleted: id };
}
function readOnlyRefusal(reason: string | null): ApiError {
return new ApiError(
'refused',
reason ?? 'This viewer is running read-only, so trails cannot be saved.',
`Restart without --read-only to let the viewer write trails into ${TRAILS_RELATIVE_DIR}.`
);
}
function parseSaveRequest(body: unknown): { name: string; note: string; hops: SaveTrailRequest['hops'] } {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw badRequest('A trail is saved from a JSON object: { name, hops }.');
}
const value = body as Record<string, unknown>;
const name = typeof value.name === 'string' ? value.name.trim().replace(/\s+/g, ' ') : '';
if (name === '') throw badRequest('A saved trail needs a name.');
if (name.length > MAX_TRAIL_NAME) {
throw badRequest(`That name is too long (max ${MAX_TRAIL_NAME} characters).`);
}
const note = typeof value.note === 'string' ? value.note.trim() : '';
if (note.length > MAX_TRAIL_NOTE) {
throw badRequest(`That note is too long (max ${MAX_TRAIL_NOTE} characters).`);
}
if (!Array.isArray(value.hops) || value.hops.length === 0) {
throw badRequest('A saved trail needs at least one hop.');
}
if (value.hops.length > MAX_TRAIL_HOPS) {
throw badRequest(`A saved trail can hold at most ${MAX_TRAIL_HOPS} hops.`);
}
const hops: SaveTrailRequest['hops'] = [];
for (const entry of value.hops) {
if (typeof entry !== 'object' || entry === null) throw badRequest('Each hop is { dir, id }.');
const hop = entry as Record<string, unknown>;
if (typeof hop.id !== 'string' || hop.id === '') throw badRequest('Each hop needs an id.');
hops.push({ id: hop.id, ...(typeof hop.dir === 'string' ? { dir: hop.dir } : {}) });
}
return { name, note, hops };
}
/* ---------------------------------------------------------------- author -- */
/**
* Who to record as the author.
*
* Git's `user.name` first, because a trail is a thing one person wrote for
* others to read and that is the name they already sign work with in this
* project; the OS user is the fallback. Read ONCE per process — `git config` is
* a subprocess, and a save should not pay for it twice — and never sent
* anywhere: it goes into a file inside the user's own `.codegraph/`.
*/
let cachedAuthor: string | null = null;
export function trailAuthor(projectRoot: string): string {
if (cachedAuthor !== null) return cachedAuthor;
cachedAuthor = gitUserName(projectRoot) ?? osUserName() ?? '';
return cachedAuthor;
}
/** Test seam: forget the cached author. */
export function resetTrailAuthor(): void {
cachedAuthor = null;
}
function gitUserName(projectRoot: string): string | null {
try {
const out = execFileSync('git', ['config', 'user.name'], {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 2_000,
stdio: ['ignore', 'pipe', 'ignore'],
});
const name = out.trim();
return name === '' ? null : name.slice(0, 120);
} catch {
// No git, no config, not a repository — all ordinary. Fall through.
return null;
}
}
function osUserName(): string | null {
try {
const name = os.userInfo().username.trim();
return name === '' ? null : name.slice(0, 120);
} catch {
return null;
}
}
+46 -10
View File
@@ -1,11 +1,16 @@
/**
* The `codegraph ui` server.
*
* A loopback-only, read-only `node:http` server that hands the browser the
* built viewer (`dist/viewer/`) and, through the JSON API mounted on the `api`
* seam below (`./api`), a read-only view of one indexed project. No framework,
* no new dependency: it answers GET, serves files, and refuses everything
* else.
* A loopback-only `node:http` server that hands the browser the built viewer
* (`dist/viewer/`) and, through the JSON API mounted on the `api` seam below
* (`./api`), a view of one indexed project. No framework, no new dependency: it
* answers GET, serves files, and refuses everything else.
*
* It is a reader with one exception, added deliberately and scoped as narrowly
* as it could be: `POST`/`DELETE /api/trails` saves and removes the reader's own
* named trails, as JSON files under `.codegraph/ui/trails/`. Nothing else it
* serves has a side effect, no other path accepts a write, and `--read-only`
* turns even that one off. See `security.ts` for what a write has to carry.
*
* The interesting part is not the routing, it is the boundary in `security.ts`.
* Read that first.
@@ -17,9 +22,13 @@ import * as path from 'path';
import { resolveViewerDir } from './assets';
import {
ALLOWED_METHODS,
READ_METHODS,
WRITE_HEADER,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
isWriteMethod,
isWriteRequest,
resolveStaticAsset,
} from './security';
import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static';
@@ -35,10 +44,15 @@ export {
} from './constants';
export {
ALLOWED_METHODS,
READ_METHODS,
WRITE_HEADER,
WRITE_METHODS,
PathRefusalError,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
isWriteMethod,
isWriteRequest,
resolveProjectFile,
resolveStaticAsset,
} from './security';
@@ -58,7 +72,11 @@ export interface UiRequestContext {
query: URLSearchParams;
/** Absolute path of the indexed project this server is reading. */
projectRoot: string;
/** The request method — `GET` or `HEAD`; nothing else reaches a handler. */
/**
* The request method. `GET` or `HEAD` for every read; `POST` or `DELETE`
* only for a request that already passed {@link isWriteRequest}, which is
* `/api/trails` and nothing else.
*/
method: string;
}
@@ -66,9 +84,9 @@ export interface UiRequestContext {
* A handler mounted under `/api/`. Returns `true` when it answered the request
* (i.e. wrote a response), `false` to fall through to a 404.
*
* This is the seam the read-only JSON API plugs into. Everything it serves out
* of the user's repository must go through `resolveProjectFile` — see
* `security.ts`.
* This is the seam the JSON API plugs into. Everything it reads out of — or
* writes into — the user's repository must go through `resolveProjectFile`;
* see `security.ts`.
*/
export type UiApiHandler = (
req: http.IncomingMessage,
@@ -228,7 +246,7 @@ async function handleRequest(
if (!ALLOWED_METHODS.includes(method)) {
res.setHeader('Allow', ALLOWED_METHODS.join(', '));
sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method);
sendText(res, 405, `codegraph ui does not answer ${method}.`, method);
return;
}
@@ -258,6 +276,24 @@ async function handleRequest(
// the viewer parses these responses, and a text/plain body here would surface
// as a parse error instead of the refusal it actually is.
const jsonNamespace = rawPath === '/api' || rawPath.startsWith('/api/');
// The one place this server stops being a pure reader. A write has to be
// under /api/ and carry the marker header — see `isWriteRequest` for what
// that closes that Host and Origin do not.
if (isWriteMethod(method)) {
const verdict = isWriteRequest(rawPath, {
marker: readHeader(req, WRITE_HEADER),
contentType: readHeader(req, 'content-type'),
});
if (!verdict.ok) {
if (!jsonNamespace) res.setHeader('Allow', READ_METHODS.join(', '));
const body = `Refused: ${verdict.reason}`;
if (jsonNamespace) sendJson(res, 403, { error: body, code: 'refused' }, method);
else sendText(res, 405, body, method);
return;
}
}
if (!isSafeRequestPath(rawPath)) {
if (jsonNamespace) {
sendJson(res, 404, { error: 'Not found', code: 'not-found' }, method);
+64 -4
View File
@@ -20,8 +20,11 @@
* - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what
* keeps a cross-origin reader from seeing a response body even if it does
* reach us. There is deliberately no way to turn this on.
* - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side
* effect, so there is no state for a forged request to change.
* - **GET/HEAD everywhere; POST/DELETE only under `/api/`, and only for a
* request that could not have been forged by a form.** See
* {@link isWriteRequest} below — the viewer went from a pure reader to one
* that saves trails into `.codegraph/ui/`, and that is the entire change to
* this boundary.
* - **Every path resolves through {@link validatePathWithinRoot}** — the same
* chokepoint the MCP read sinks use, which catches `../` traversal AND
* in-tree symlinks pointing out of the root (#527).
@@ -40,8 +43,65 @@ export { PathRefusalError };
*/
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '::1']);
/** HTTP methods the viewer server answers. Everything else is 405. */
export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD'];
/** Methods that answer anywhere: the viewer's assets and every read endpoint. */
export const READ_METHODS: readonly string[] = ['GET', 'HEAD'];
/**
* Methods that answer under `/api/` only, and only for a request carrying
* {@link WRITE_HEADER}. The viewer's one write is a saved trail.
*/
export const WRITE_METHODS: readonly string[] = ['POST', 'DELETE'];
/** HTTP methods the viewer server answers at all. Everything else is 405. */
export const ALLOWED_METHODS: readonly string[] = [...READ_METHODS, ...WRITE_METHODS];
/**
* The header a write has to carry.
*
* Belt and braces behind the `Host` and `Origin` checks, and worth the two
* lines because it fails *differently*: a custom request header cannot be sent
* cross-origin without a CORS preflight, and this server answers no preflight
* and sends no `Access-Control-*` header, so the browser never issues the real
* request. That closes the one shape those checks lean on a header for — a
* `<form method="post">` submitted from another page, which sends no `Origin`
* in some older browsers and cannot set a custom header in any of them.
*/
export const WRITE_HEADER = 'x-codegraph-ui';
/** The content type a write body must declare. A form can send none of these. */
const WRITE_CONTENT_TYPE = 'application/json';
export function isWriteMethod(method: string): boolean {
return WRITE_METHODS.includes(method);
}
/**
* Whether a mutating request is one the viewer could have made.
*
* @param method the request method, already known to be a write method
* @param pathname the raw request path
* @param headers `x-codegraph-ui` and, for a body-carrying method, `content-type`
*/
export function isWriteRequest(
pathname: string,
headers: { marker: string | undefined; contentType: string | undefined }
): { ok: true } | { ok: false; reason: string } {
// Writes live under /api/ and nowhere else. The static side of this server
// serves a built bundle; there is nothing there to POST to.
if (pathname !== '/api' && !pathname.startsWith('/api/')) {
return { ok: false, reason: 'Only the /api/ endpoints accept writes.' };
}
if (headers.marker === undefined || headers.marker.trim() === '') {
return { ok: false, reason: `A write must carry the ${WRITE_HEADER} header.` };
}
if (headers.contentType !== undefined) {
const type = headers.contentType.split(';')[0]?.trim().toLowerCase();
if (type !== '' && type !== WRITE_CONTENT_TYPE) {
return { ok: false, reason: `A write body must be ${WRITE_CONTENT_TYPE}.` };
}
}
return { ok: true };
}
interface HostParts {
hostname: string;