Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ The self-hosted server does not include built-in authentication. Options for pro
- **Reverse proxy**: Place behind nginx, Caddy, or Traefik with HTTP basic auth, OAuth2 proxy, or mTLS.
- **Local only**: Set `HOST=127.0.0.1` to bind to localhost only.

Every response carries baseline hardening headers: `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, and `X-Frame-Options: SAMEORIGIN`. There is intentionally no `Content-Security-Policy` — the viewer's markdown, code, and mermaid renderers rely on inline styles that a strict policy would break. Add a CSP at your reverse proxy if your threat model requires one.

### Future encrypted short links

A future mode could encrypt the payload in the browser or agent, store only ciphertext under the UUID, and keep the decryption key in the URL fragment. That would make the short-link server unable to read plaintext while still giving users a short public URL shape. This repository does not implement that mode today.
Expand Down
63 changes: 47 additions & 16 deletions selfhosted/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,14 @@ function errorPage(title: string, message: string): string {
* - `GET /*` — serve static files from the build output
*/
async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
// Conservative security headers on every response. `nosniff` stops MIME-type confusion;
// `no-referrer` keeps the artifact UUID (in the path) out of the Referer sent to any third-party
// resource a rendered artifact loads; `SAMEORIGIN` blocks cross-origin framing of the viewer.
// No CSP: the markdown/code/mermaid renderers rely on inline styles a strict policy would break.
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Frame-Options", "SAMEORIGIN");

const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
const pathname = url.pathname.replace(/\/+$/, "") || "/";
const method = req.method?.toUpperCase() ?? "GET";
Expand Down Expand Up @@ -262,17 +270,29 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise

// POST /api/artifacts — create
if (pathname === "/api/artifacts" && method === "POST") {
let parsed: unknown;
try {
const body = JSON.parse(await readBody(req));
const validation = validatePayload(body.payload);
if (!validation.ok) {
jsonResponse(res, 400, { error: validation.message });
return;
}
const result = createArtifact(body.payload);
jsonResponse(res, 201, result);
parsed = JSON.parse(await readBody(req));
} catch {
jsonResponse(res, 400, { error: "Invalid request body." });
return;
}
// Tolerate any JSON value (including `null` or a primitive) without throwing; a non-object body
// simply has no payload and is rejected as a 400 below.
const payload = (parsed as { payload?: unknown } | null)?.payload;
const validation = validatePayload(payload);
if (!validation.ok) {
jsonResponse(res, 400, { error: validation.message });
return;
}
try {
// validatePayload has confirmed payload is a string.
const result = createArtifact(payload as string);
jsonResponse(res, 201, result);
} catch {
// The request was well-formed; persistence failed (e.g. disk full). Report a server error
// instead of masking it as a 400 client error.
jsonResponse(res, 500, { error: "Failed to store artifact." });
}
return;
}
Expand All @@ -298,21 +318,32 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
}

if (method === "PUT") {
let parsed: unknown;
try {
const body = JSON.parse(await readBody(req));
const validation = validatePayload(body.payload);
if (!validation.ok) {
jsonResponse(res, 400, { error: validation.message });
return;
}
const row = updateArtifact(artifactId, body.payload);
parsed = JSON.parse(await readBody(req));
} catch {
jsonResponse(res, 400, { error: "Invalid request body." });
return;
}
// Tolerate any JSON value (including `null` or a primitive) without throwing; a non-object body
// simply has no payload and is rejected as a 400 below.
const payload = (parsed as { payload?: unknown } | null)?.payload;
const validation = validatePayload(payload);
if (!validation.ok) {
jsonResponse(res, 400, { error: validation.message });
return;
}
try {
// validatePayload has confirmed payload is a string.
const row = updateArtifact(artifactId, payload as string);
if (!row) {
jsonResponse(res, 404, { error: "Artifact not found or expired." });
return;
}
jsonResponse(res, 200, row);
} catch {
jsonResponse(res, 400, { error: "Invalid request body." });
// The request was well-formed; persistence failed. Report a server error, not a 400.
jsonResponse(res, 500, { error: "Failed to store artifact." });
}
return;
}
Expand Down
166 changes: 166 additions & 0 deletions tests/selfhosted/response-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// @vitest-environment node
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { spawn, type ChildProcess } from "node:child_process";
import net from "node:net";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

// Pins the self-hosted server's response contract: conservative security headers on every response,
// and correct status codes for the create/update API (malformed input is a 400 client error; a
// well-formed request that fails to persist is a 500, not a masked 400). The server self-executes on
// import, so it is exercised over an ephemeral port in a spawned child rather than imported directly.

const repoRoot = process.cwd();
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (address && typeof address === "object") {
resolve(address.port);
} else {
reject(new Error("Could not allocate a local test port."));
}
});
});
});
}

function createFixture(): { root: string; outDir: string } {
const root = mkdtempSync(path.join(tmpdir(), "agent-render-response-"));
const outDir = path.join(root, "out");
mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, "index.html"), "<!doctype html><title>agent-render</title>");
return { root, outDir };
}

async function waitForReady(url: string, child: ChildProcess): Promise<void> {
const startedAt = Date.now();
let lastError: unknown;

while (Date.now() - startedAt < 5000) {
if (child.exitCode !== null) {
break;
}

try {
const response = await fetch(url, { method: "HEAD" });
if (response.status !== 404) {
return;
}
} catch (error) {
Comment on lines +52 to +57

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 waitForReady considers any non-404 response as "ready", including a 500 from a misconfigured server. If the spawned process starts but immediately fails to serve the fixture (wrong OUT_DIR, missing DB init, etc.), the test suite proceeds and every assertion fails with a confusing status-code mismatch rather than a clear "server not ready" error. Using response.ok (i.e. 200–299) as the success condition surfaces startup failures immediately.

Suggested change
try {
const response = await fetch(url, { method: "HEAD" });
if (response.status !== 404) {
return;
}
} catch (error) {
try {
const response = await fetch(url, { method: "HEAD" });
if (response.ok) {
return;
}
} catch (error) {

Fix in Codex

lastError = error;
}

await new Promise((resolve) => setTimeout(resolve, 50));
}

throw lastError instanceof Error
? lastError
: new Error(`Self-hosted server did not respond at ${url}.`);
}

describe("selfhosted response contract", () => {
let fixture: { root: string; outDir: string };
let child: ChildProcess;
let port: number;
let base: string;

beforeAll(async () => {
fixture = createFixture();
port = await getFreePort();
base = `http://127.0.0.1:${port}`;
child = spawn(
process.execPath,
["--import", "tsx", path.join(repoRoot, "selfhosted", "server.ts")],
{
cwd: repoRoot,
env: {
...process.env,
PORT: String(port),
HOST: "127.0.0.1",
OUT_DIR: fixture.outDir,
DB_PATH: path.join(fixture.root, "agent-render.db"),
},
stdio: "ignore",
},
);
await waitForReady(`${base}/index.html`, child);
});

afterAll(async () => {
if (child.exitCode === null) {
await new Promise<void>((resolve) => {
child.once("close", () => resolve());
child.kill();
});
}
rmSync(fixture.root, { recursive: true, force: true });
});

it("sets security headers on static responses", async () => {
const response = await fetch(`${base}/index.html`);
expect(response.status).toBe(200);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
});

it("sets security headers on API responses", async () => {
const response = await fetch(`${base}/api/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: "psecurity-headers" }),
});
expect(response.status).toBe(201);
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
expect(response.headers.get("x-frame-options")).toBe("SAMEORIGIN");
});

it("creates an artifact for a well-formed request", async () => {
const response = await fetch(`${base}/api/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: "phello" }),
});
expect(response.status).toBe(201);
const created = (await response.json()) as { id: string; expires_at: string };
expect(created.id).toMatch(UUID_RE);
expect(typeof created.expires_at).toBe("string");
});

it("rejects a malformed JSON body with 400, not 500", async () => {
const response = await fetch(`${base}/api/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{ not valid json",
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toEqual({ error: "Invalid request body." });
});

it("rejects an empty payload with 400", async () => {
const response = await fetch(`${base}/api/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ payload: "" }),
});
expect(response.status).toBe(400);
});

it("rejects a non-object JSON body (null) with 400, not 500", async () => {
// `JSON.parse("null")` succeeds, so payload extraction must not throw on a non-object body.
const response = await fetch(`${base}/api/artifacts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "null",
});
expect(response.status).toBe(400);
});
});
Loading