Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 8 additions & 2 deletions api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
// Checks the app through HTTP injection without connecting to a database.
import { afterAll, describe, expect, test } from "bun:test";
import { buildApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { createDb } from "./db.ts";

// A config made from a fixed object, so the test never depends on this machine's environment.
// DATABASE_URL is required but harmless here: these tests never connect to it.
const app = buildApp(loadConfig({ GIT_SHA: "abc123", DATABASE_URL: "postgres://test@localhost/test" }), {
const sql = createDb(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" }));
const app = buildApp(loadConfig({ GIT_SHA: "abc123", DATABASE_URL: "postgres://test@localhost/test" }), { sql }, {
logger: false,
});
afterAll(() => app.close());
afterAll(async () => {
await app.close();
await sql.end({ timeout: 5 });
});

describe("health routes", () => {
test("GET /health/live answers ok", async () => {
Expand Down
16 changes: 14 additions & 2 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@
// It never listens on a port itself. server.ts does that, so tests can call
// buildApp() and send fake requests with app.inject().
import Fastify, { LogController, type FastifyInstance } from "fastify";
import type { Sql } from "./db.ts";
import { v1Routes } from "./routes/v1.ts";
import type { Config } from "./config.ts";
import { errorHandler, notFoundHandler } from "./error-handler.ts";
import { healthRoutes } from "./routes/health.ts";

export function buildApp(config: Config, options: { logger?: boolean } = {}): FastifyInstance {
export function buildApp(
config: Config,
deps: { sql: Sql },
options: { logger?: boolean; logStream?: { write(line: string): void } } = {},
): FastifyInstance {
const app = Fastify({
// JSON logs (pino). Tests pass logger: false to keep their output clean.
logger: options.logger === false ? false : { level: config.LOG_LEVEL },
logger: options.logger === false ? false : { level: config.LOG_LEVEL, stream: options.logStream },
bodyLimit: 256 * 1024, // 256 KiB, bigger bodies get 413
genReqId: () => crypto.randomUUID(), // a unique id for every request
logController: new LogController({
Expand All @@ -20,6 +26,10 @@ export function buildApp(config: Config, options: { logger?: boolean } = {}): Fa
}),
});

// Step 1: accept JSON bodies and declare the per-request identity.
app.removeContentTypeParser("text/plain");
app.decorateRequest("owner", null);

// Every response tells the client its request id: the same id as in our logs,
// so "it failed" plus the id is enough to find the exact log lines.
app.addHook("onRequest", async (request, reply) => {
Expand All @@ -30,7 +40,9 @@ export function buildApp(config: Config, options: { logger?: boolean } = {}): Fa
app.setErrorHandler(errorHandler);
app.setNotFoundHandler(notFoundHandler);

// Step 2: only the v1 plugin inherits the authentication hook.
app.register(healthRoutes, { config });
app.register(v1Routes, { prefix: "/v1", sql: deps.sql });

return app;
}
42 changes: 42 additions & 0 deletions api/src/auth/api-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Checks key generation and defines the header parser's required behavior.
import { describe, expect, test } from "bun:test";
import { generateApiKey, hashApiKey, parseBearerKey } from "./api-key.ts";

const key = "relay_live_" + "a".repeat(43);

// Step 1: schemes are case-insensitive, but the separator is one space.
describe("parseBearerKey", () => {
for (const scheme of ["Bearer", "bearer", "BEARER"]) {
test(`${scheme} with a valid key returns the key`, () => {
expect(parseBearerKey(`${scheme} ${key}`)).toBe(key);
});
}
const invalid: [string, string | undefined][] = [
["undefined", undefined], ["empty", ""], ["Basic", "Basic abc"], ["scheme alone", "Bearer"],
["two spaces", `Bearer ${key}`], ["tab", `Bearer\t${key}`], ["trailing space", `Bearer ${key} `],
["test prefix", "Bearer relay_test_" + "a".repeat(43)],
["42 characters", "Bearer relay_live_" + "a".repeat(42)],
["44 characters", "Bearer relay_live_" + "a".repeat(44)],
...["+", "/", "="].map((character): [string, string] =>
[character, "Bearer relay_live_" + "a".repeat(42) + character]),
["combined headers", `Bearer ${key}, Bearer ${key}`],
];
for (const [name, header] of invalid) {
test(`rejects ${name}`, () => expect(parseBearerKey(header)).toBeNull());
}
});

// Step 2: randomness, public prefix and database hash describe the same key.
describe("generateApiKey", () => {
test("has the exact key format and a safe 15-character prefix", () => {
const generated = generateApiKey();
expect(generated.key).toMatch(/^relay_live_[A-Za-z0-9_-]{43}$/);
expect(generated.prefix).toBe(generated.key.slice(0, 15));
});
test("two generated keys differ", () => expect(generateApiKey().key).not.toBe(generateApiKey().key));
test("hash matches SHA-256 of the key and has 32 bytes", () => {
const generated = generateApiKey();
expect(generated.hash).toEqual(hashApiKey(generated.key));
expect(generated.hash.length).toBe(32);
});
});
31 changes: 31 additions & 0 deletions api/src/auth/api-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Generates API keys and hashes them without storing their secret text.
import { createHash, randomBytes } from "node:crypto";

// Step 1: the public format and the one-way hash stored in Postgres.
export const KEY_PREFIX = "relay_live_";
export const KEY_RANDOM_LENGTH = 43;

export function hashApiKey(key: string): Buffer {
return createHash("sha256").update(key, "utf8").digest();
}

export function generateApiKey(): { key: string; prefix: string; hash: Buffer } {
const random = randomBytes(32).toString("base64url");
const key = KEY_PREFIX + random;
return { key, prefix: KEY_PREFIX + random.slice(0, 4), hash: hashApiKey(key) };
}

// Step 2: check the header before any database query.
// The word "Bearer" is case-insensitive (RFC 9110), so "bearer" and "BEARER" work too.
// The key after exactly one space is strict: KEY_PREFIX + KEY_RANDOM_LENGTH base64url characters.
// Every failure returns the same null (and so the same 401), so nobody learns which part was wrong.
const SCHEME = "bearer ";
const KEY_REGEX = new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_RANDOM_LENGTH}}$`);

export function parseBearerKey(header: string | undefined): string | null {
if (typeof header !== "string") return null;
if (header.slice(0, SCHEME.length).toLowerCase() !== SCHEME) return null;
const key = header.slice(SCHEME.length);
return KEY_REGEX.test(key) ? key : null;
}

30 changes: 30 additions & 0 deletions api/src/auth/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Finds the owner of a single active API key before a protected route runs.
import type { onRequestAsyncHookHandler } from "fastify";
import type { Sql } from "../db.ts";
import { unauthorized } from "../errors.ts";
import { findOwnerByKeyHash } from "../repositories/owners.ts";
import { hashApiKey, parseBearerKey } from "./api-key.ts";

declare module "fastify" {
interface FastifyRequest {
owner: { id: string; name: string; keyPrefix: string } | null;
}
}

// Step 1: reject duplicate headers before Bun can hide the second value.
export function authenticate(sql: Sql): onRequestAsyncHookHandler {
return async (request) => {
let count = 0;
for (let i = 0; i < request.raw.rawHeaders.length; i += 2) {
if (request.raw.rawHeaders[i]?.toLowerCase() === "authorization") count++;
}
if (count > 1) throw unauthorized();
const key = parseBearerKey(request.headers.authorization);
if (key === null) throw unauthorized();

// Step 2: database failures propagate to the shared error handler.
const owner = await findOwnerByKeyHash(sql, hashApiKey(key));
if (owner === null) throw unauthorized();
request.owner = { id: owner.ownerId, name: owner.ownerName, keyPrefix: owner.keyPrefix };
};
}
35 changes: 35 additions & 0 deletions api/src/cli/create-owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Creates an owner and prints its API key once, without using a logger.
// Usage: bun src/cli/create-owner.ts --name "Acme"
// Production: kubectl -n relay exec deploy/relay-api -- bun src/cli/create-owner.ts --name "Acme"
import { loadConfig } from "../config.ts";
import { createDb, type Sql } from "../db.ts";
import { OwnerName } from "../schemas/owners.ts";
import { createOwnerWithKey } from "../services/owners.ts";

// Step 1: validate arguments before making a pool or writing anything.
let sql: Sql | undefined;
try {
const args = process.argv.slice(2);
if (args.length !== 2 || args[0] !== "--name") throw new Error("Usage: --name <owner name>");
const parsed = OwnerName.safeParse(args[1]);
if (!parsed.success) throw new Error("Name must be 1–100 characters without control characters");
sql = createDb(loadConfig());
const { owner, key } = await createOwnerWithKey(sql, parsed.data);

// Step 2: stdout is the only place the full key is ever printed.
console.log(`✅ Owner created: ${owner.name} (${owner.id})`);
console.log(`API key (shown once, store it now in a password manager): ${key}`);
} catch (error) {
// Never print database errors: detail and where may contain secret row values.
const reason = sql ? "Could not create owner" : error instanceof Error ? error.message : "Could not create owner";
console.error(`❌ ${reason}`);
process.exitCode = 1;
} finally {
// Step 3: close even after a failed insert, without leaking close-error details.
try {
await sql?.end({ timeout: 5 });
} catch {
console.error("❌ Could not close database connections");
process.exitCode = 1;
}
}
63 changes: 60 additions & 3 deletions api/src/error-handler.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// Checks the app through HTTP injection without connecting to a database.
import { afterAll, describe, expect, test } from "bun:test";
import { z } from "zod";
import { buildApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { conflict } from "./errors.ts";
import { createDb } from "./db.ts";
import { AppError, conflict } from "./errors.ts";

// A harmless database URL: these tests never connect to it.
const app = buildApp(loadConfig({ DATABASE_URL: "postgres://test@localhost/test" }), { logger: false });
const sql = createDb(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" }));
const app = buildApp(loadConfig({ DATABASE_URL: "postgres://test@localhost/test" }), { sql }, { logger: false });

// Test-only routes that fail on purpose, one for each kind of error.
app.get("/test/zod", async () => z.object({ name: z.string() }).parse({}));
Expand All @@ -24,7 +27,15 @@ app.get("/test/db-bug", async () => {
throw Object.assign(new Error('relation "nope" does not exist'), { code: "42P01" });
});

afterAll(() => app.close());
// Step 1: details remain public only when deliberately supplied by application code.
app.get("/test/details", async () => {
throw new AppError(422, "validation_failed", "Invalid input", [{ path: "name", message: "Required" }]);
});

afterAll(async () => {
await app.close();
await sql.end({ timeout: 5 });
});

describe("error handler", () => {
test("unknown route → 404 not_found, in our error shape", async () => {
Expand Down Expand Up @@ -102,3 +113,49 @@ describe("error handler", () => {
expect(res.json().error.code).toBe("internal_error");
});
});

// Step 2: the default text parser must not bypass the JSON-only contract.
for (const contentType of ["text/plain", undefined]) {
test(`echo rejects ${contentType ?? "missing content type"}`, async () => {
const res = await app.inject({
method: "POST", url: "/test/echo", payload: "hello",
headers: contentType ? { "content-type": contentType } : {},
});
expect(res.statusCode).toBe(415);
expect(res.json().error.code).toBe("unsupported_media_type");
});
}
test("JSON with a charset passes", async () => {
const res = await app.inject({ method: "POST", url: "/test/echo",
headers: { "content-type": "application/json; charset=utf-8" }, payload: '{"ok":true}' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
});
test("AppError carries deliberately supplied details", async () => {
const res = await app.inject({ method: "GET", url: "/test/details" });
expect(res.statusCode).toBe(422);
expect(res.json().error.details).toEqual([{ path: "name", message: "Required" }]);
});

// Step 3: a database row or JSON payload must never reach the log stream.
test("Postgres error logs contain the code without secret detail or where", async () => {
const lines: string[] = [];
const logged = buildApp(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" }), { sql },
{ logStream: { write: (line) => { lines.push(line); } } });
logged.get("/test/pg-secret", async () => {
throw Object.assign(new Error("CHECK failed"), {
name: "PostgresError", code: "23514", severity: "ERROR",
detail: "Failing row contains (MARKER_SECRET_123)", where: "payload MARKER_SECRET_123",
hint: "MARKER_SECRET_123", internal_query: "MARKER_SECRET_123",
});
});
try {
const res = await logged.inject({ method: "GET", url: "/test/pg-secret" });
expect(res.statusCode).toBe(500);
expect(res.json().error.code).toBe("internal_error");
expect(lines.join("")).toContain("23514");
expect(lines.join("")).not.toContain("MARKER_SECRET_123");
} finally {
await logged.close();
}
});
20 changes: 17 additions & 3 deletions api/src/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ function send(
return reply.code(status).send({ error });
}

// Step 1: allow only safe database metadata into logs. On Postgres 18.6 a CHECK
// failure's detail holds the whole row, including signing secrets; JSON errors can
// put the payload in where. Never log detail, where, hint or internal_query.
function logSafe(error: FastifyError) {
if (error.name !== "PostgresError") return error;
const dbError = error as FastifyError & Record<string, unknown>;
return {
type: "PostgresError", code: dbError.code, message: dbError.message,
severity: dbError.severity, table: dbError.table_name,
constraint: dbError.constraint_name, routine: dbError.routine,
};
}

// Step 2: translate errors once, without exposing internal database details.
export function errorHandler(error: FastifyError, request: FastifyRequest, reply: FastifyReply) {

if (error instanceof ZodError) {
Expand All @@ -33,13 +47,13 @@ export function errorHandler(error: FastifyError, request: FastifyRequest, reply
}

if (error instanceof AppError) {
return send(reply, request, error.status, error.code, error.message);
return send(reply, request, error.status, error.code, error.message, error.details);
}

// The database can't be reached right now (a restart, a short blip). The pod stays up and
// ready; only this request fails, with a clear "try again in 5 s" instead of a vague 500.
if (isDatabaseUnavailable(error)) {
request.log.warn({ err: error }, "⚠️ database unavailable, answered 503");
request.log.warn({ err: logSafe(error) }, "⚠️ database unavailable, answered 503");
reply.header("retry-after", "5");
return send(reply, request, 503, "service_unavailable", "The service is briefly unavailable, try again shortly");
}
Expand All @@ -49,7 +63,7 @@ export function errorHandler(error: FastifyError, request: FastifyRequest, reply
return send(reply, request, error.statusCode, code, error.message);
}

request.log.error(error);
request.log.error(logSafe(error), "❌ unexpected error, answered 500");

return send(reply, request, 500, "internal_error", "Something went wrong on our side");
}
Expand Down
5 changes: 4 additions & 1 deletion api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export class AppError extends Error {
readonly status: number;
readonly code: string;

constructor(status: number, code: string, message: string) {
constructor(status: number, code: string, message: string, readonly details?: { path: string; message: string }[]) {
super(message);
this.name = "AppError";
this.status = status;
Expand All @@ -15,3 +15,6 @@ export class AppError extends Error {
export const notFound = (what: string) => new AppError(404, "not_found", `${what} not found`);

export const conflict = (code: string, message: string) => new AppError(409, code, message);

// Step 1: every authentication failure has the same public answer.
export const unauthorized = () => new AppError(401, "unauthorized", "A valid API key is required");
28 changes: 28 additions & 0 deletions api/src/repositories/owners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Reads and writes owners and their hashed API keys using parameterized SQL.
import type postgres from "postgres";
import type { Sql } from "../db.ts";

type Db = Sql | postgres.TransactionSql;
type Owner = { id: string; name: string };
type KeyOwner = { ownerId: string; ownerName: string; keyPrefix: string };

// Step 1: writes accept a transaction handle so owner and key are saved together.
export async function insertOwner(db: Db, name: string): Promise<Owner> {
const [owner] = await db<Owner[]>`INSERT INTO owners (name) VALUES (${name}) RETURNING id, name`;
if (!owner) throw new Error("Owner insert returned no row");
return owner;
}

export async function insertApiKey(db: Db, key: { ownerId: string; prefix: string; keyHash: Buffer }): Promise<void> {
await db`INSERT INTO api_keys (owner_id, prefix, key_hash)
VALUES (${key.ownerId}, ${key.prefix}, ${key.keyHash})`;
}

// Step 2: this lookup has no owner filter: it discovers the owner from an active key.
export async function findOwnerByKeyHash(db: Db, keyHash: Buffer): Promise<KeyOwner | null> {
const [owner] = await db<KeyOwner[]>`
SELECT owners.id AS "ownerId", owners.name AS "ownerName", api_keys.prefix AS "keyPrefix"
FROM api_keys JOIN owners ON owners.id = api_keys.owner_id
WHERE api_keys.key_hash = ${keyHash} AND api_keys.revoked_at IS NULL`;
return owner ?? null;
}
12 changes: 12 additions & 0 deletions api/src/routes/me.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Returns only the authenticated owner's identity and the safe key prefix.
import type { FastifyPluginAsync } from "fastify";
import { unauthorized } from "../errors.ts";

// Step 1: build the response field by field so the secret key cannot be included.
export const meRoutes: FastifyPluginAsync = async (app) => {
app.get("/me", async (request) => {
const owner = request.owner;
if (owner === null) throw unauthorized();
return { owner: { id: owner.id, name: owner.name }, key: { prefix: owner.keyPrefix } };
});
};
Loading