Skip to content
Merged
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
2 changes: 2 additions & 0 deletions deploy/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ http {
# one trusted hop: TRUST_PROXY=1 on the api reads the right-most entry, the address nginx saw
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# one id per request (32 random hex), replacing any the client sent; the api (TRUST_PROXY=1) keeps it
proxy_set_header X-Request-ID $request_id;
# a replica that is shutting down answers 503: try the next one (nginx never retries a POST that reached a replica)
proxy_next_upstream error timeout http_502 http_503;
proxy_read_timeout 120s;
Expand Down
2 changes: 1 addition & 1 deletion docs/09-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ variables already set in the real environment win over the file.
| `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) |
| `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured |
| `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker |
| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored |
| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` |
| `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) |
| `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` |
| `SESSION_IDLE_MS` | `43200000` (12 h) | |
Expand Down
79 changes: 76 additions & 3 deletions llms-full.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @mgpl-diagnostics/api — full documentation and source

> Generated by scripts/llms-full.mjs. Do not edit by hand. 103 files.
> Generated by scripts/llms-full.mjs. Do not edit by hand. 104 files.

## Contents

Expand Down Expand Up @@ -69,6 +69,7 @@
- src/jobs/types.ts
- src/lifecycle.ts
- src/main.ts
- src/request-id.ts
- src/schema/auth.graphql.ts
- src/schema/base.graphql.ts
- src/schema/billing.graphql.ts
Expand Down Expand Up @@ -1518,7 +1519,7 @@ variables already set in the real environment win over the file.
| `IMPORT_MAX_BYTES` | `15728640` (15 MiB) | Largest partner-lab PDF ([18](18-imports.md)) |
| `IMPORT_INLINE_MAX_BYTES` | `5242880` (5 MiB) | Larger partner PDFs are read by the worker when Redis is configured |
| `IMPORT_WAIT_MS` | `60000` | How long `importExternalReport` waits for the worker |
| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored |
| `TRUST_PROXY` | `false` | How many proxies in front of the API append to `X-Forwarded-For`: `true` = 1 (a load balancer), `2` for CDN + load balancer. Login limits and sessions then see the client IP; entries left of the trusted proxies (which any client can send) are ignored. Through a trusted proxy, its `X-Request-ID` (when well-formed) becomes the request id; otherwise each request gets a UUIDv7, sent back in `x-request-id` and logged as `reqId` |
| `TLS_FINGERPRINT_HEADER` | empty (off) | Header the TLS-terminating proxy sets to the client's JA4 (or JA3) TLS fingerprint, e.g. `x-ja4`. Read only through `TRUST_PROXY` proxies (refused at startup without one); stored hashed on the session, a change audited ([06-auth-and-permissions.md](06-auth-and-permissions.md#tls-fingerprint)) |
| `MGPL_SAMPLES` | on in development | Demo report/invoice samples in `documents` |
| `SESSION_IDLE_MS` | `43200000` (12 h) | |
Expand Down Expand Up @@ -8262,6 +8263,56 @@ shutdownOnSignals(app, { ...config.shutdown, log: (m) => console.log(m) });
```


---

## File: src/request-id.ts

```ts
import { randomFillSync } from "node:crypto";
import type { IncomingMessage } from "node:http";

/** The header that carries the request id, in both directions. */
export const REQUEST_ID_HEADER = "x-request-id";

/**
* Ids we accept from a trusted proxy: nginx's `$request_id` (32 hex), Envoy's UUID, AWS/GCP trace ids.
* Printable, no spaces, no control characters (the id goes into every log line of the request).
*/
const INCOMING = /^[A-Za-z0-9._:\-]{8,128}$/;

const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
const bytes = new Uint8Array(16);

/**
* A UUIDv7 (RFC 9562): 48-bit Unix time in milliseconds, then 74 random bits. Unique across replicas and
* restarts, sorts by time, and does not reveal how many requests a replica has served (Fastify's default
* was a per-process counter, "req-1", "req-2", …).
*/
export function uuidv7(now = Date.now()): string {
randomFillSync(bytes);
const ms = BigInt(now);
for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
bytes[6] = (bytes[6]! & 0x0f) | 0x70; // version 7
bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10
let s = "";
for (let i = 0; i < 16; i++) s += (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + HEX[bytes[i]!];
return s;
}

/**
* Fastify's `genReqId`. With a trusted proxy in front (TRUST_PROXY ≥ 1) its X-Request-ID is kept when it
* looks like an id, so the proxy's logs and ours share one id; otherwise (no proxy, missing or malformed
* header) a new UUIDv7. Without a trusted proxy the header is client input and is ignored.
*/
export function requestIdGenerator(trustedHops: number): (req: IncomingMessage) => string {
return (req) => {
const incoming = trustedHops > 0 ? req.headers[REQUEST_ID_HEADER] : undefined;
return typeof incoming === "string" && INCOMING.test(incoming) ? incoming : uuidv7();
};
}
```


---

## File: src/schema/auth.graphql.ts
Expand Down Expand Up @@ -9563,6 +9614,7 @@ import { AUTH_MODE_HEADER } from "./graphql/cookies";
import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits";
import { buildSchema } from "./graphql/schema";
import { isDraining } from "./lifecycle";
import { REQUEST_ID_HEADER, requestIdGenerator } from "./request-id";

type ServerContext = { req: FastifyRequest; reply: FastifyReply };

Expand Down Expand Up @@ -9598,7 +9650,11 @@ function rules(introspection: boolean): Plugin {
*/
export async function buildServer(container: Container): Promise<FastifyInstance> {
const { config } = container;
const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops, genReqId: requestIdGenerator(config.trustedHops) });

// request ids: a UUIDv7 per request (or the trusted proxy's X-Request-ID, src/request-id.ts). Fastify logs it as
// reqId, Yoga reuses it (it prefers req.id) for its own logs, and every response, /health and 413s too, echoes it.
app.addHook("onRequest", async (req, reply) => void reply.header(REQUEST_ID_HEADER, req.id));

// security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the
// client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY).
Expand Down Expand Up @@ -12984,6 +13040,23 @@ async function fromConfig(url: string) {
ok((await sessionIp({ TRUST_PROXY: "1" })) === "203.0.113.9", "[config] TRUST_PROXY=1: the IP the load balancer saw, never the spoofed left entry");
ok((await sessionIp({})) === "127.0.0.1", "[config] no TRUST_PROXY: X-Forwarded-For is ignored");

// request ids: a UUIDv7 per request (never Fastify's "req-1" counter); X-Request-ID kept only from a trusted proxy
{
const UUID7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ids = async (env: Record<string, string>) => {
const c = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url, ...env }) });
const s = await buildServer(c);
const q = async (headers: Record<string, string> = {}) =>
String((await s.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...headers }, payload: JSON.stringify({ query: "{ health { ok } }" }) })).headers["x-request-id"]);
const out = { a: await q(), b: await q(), sent: await q({ "x-request-id": "0123456789abcdef0123456789abcdef" }), bad: await q({ "x-request-id": "evil\tid" }), health: String((await s.inject({ url: "/health?live=1" })).headers["x-request-id"]) };
await s.close();
return out;
};
const direct = await ids({}), proxied = await ids({ TRUST_PROXY: "1" });
ok(UUID7.test(direct.a) && UUID7.test(direct.b) && direct.a !== direct.b && UUID7.test(direct.health), "[config] x-request-id: a new UUIDv7 per request, on GraphQL and /health");
ok(UUID7.test(direct.sent) && proxied.sent === "0123456789abcdef0123456789abcdef" && UUID7.test(proxied.bad), "[config] x-request-id: the incoming one is kept only through TRUST_PROXY, and only when well-formed");
}

// TLS fingerprint: read from TLS_FINGERPRINT_HEADER only through trusted proxies, stored hashed, a change audited
const JA4 = "t13d1516h2_8daaf6152771_e5627efa2ab1", JA4b = "t13d1517h2_8daaf6152771_b1ff8ab2d16f";
{
Expand Down
42 changes: 42 additions & 0 deletions src/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { randomFillSync } from "node:crypto";
import type { IncomingMessage } from "node:http";

/** The header that carries the request id, in both directions. */
export const REQUEST_ID_HEADER = "x-request-id";

/**
* Ids we accept from a trusted proxy: nginx's `$request_id` (32 hex), Envoy's UUID, AWS/GCP trace ids.
* Printable, no spaces, no control characters (the id goes into every log line of the request).
*/
const INCOMING = /^[A-Za-z0-9._:\-]{8,128}$/;

const HEX = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0"));
const bytes = new Uint8Array(16);

/**
* A UUIDv7 (RFC 9562): 48-bit Unix time in milliseconds, then 74 random bits. Unique across replicas and
* restarts, sorts by time, and does not reveal how many requests a replica has served (Fastify's default
* was a per-process counter, "req-1", "req-2", …).
*/
export function uuidv7(now = Date.now()): string {
randomFillSync(bytes);
const ms = BigInt(now);
for (let i = 0; i < 6; i++) bytes[i] = Number((ms >> BigInt(8 * (5 - i))) & 0xffn);
bytes[6] = (bytes[6]! & 0x0f) | 0x70; // version 7
bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10
let s = "";
for (let i = 0; i < 16; i++) s += (i === 4 || i === 6 || i === 8 || i === 10 ? "-" : "") + HEX[bytes[i]!];
return s;
}

/**
* Fastify's `genReqId`. With a trusted proxy in front (TRUST_PROXY ≥ 1) its X-Request-ID is kept when it
* looks like an id, so the proxy's logs and ours share one id; otherwise (no proxy, missing or malformed
* header) a new UUIDv7. Without a trusted proxy the header is client input and is ignored.
*/
export function requestIdGenerator(trustedHops: number): (req: IncomingMessage) => string {
return (req) => {
const incoming = trustedHops > 0 ? req.headers[REQUEST_ID_HEADER] : undefined;
return typeof incoming === "string" && INCOMING.test(incoming) ? incoming : uuidv7();
};
}
7 changes: 6 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { AUTH_MODE_HEADER } from "./graphql/cookies";
import { allowsLargeBody, costLimit, depthLimit, importBodyLimit } from "./graphql/limits";
import { buildSchema } from "./graphql/schema";
import { isDraining } from "./lifecycle";
import { REQUEST_ID_HEADER, requestIdGenerator } from "./request-id";

type ServerContext = { req: FastifyRequest; reply: FastifyReply };

Expand Down Expand Up @@ -43,7 +44,11 @@ function rules(introspection: boolean): Plugin {
*/
export async function buildServer(container: Container): Promise<FastifyInstance> {
const { config } = container;
const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops });
const app = Fastify({ logger: config.env === "production" ? { level: "info" } : config.env === "test" ? false : { level: "warn" }, bodyLimit: Math.max(config.bodyLimit, importBodyLimit(config.imports.maxBytes)), trustProxy: (_addr: string, hop: number) => hop < config.trustedHops, genReqId: requestIdGenerator(config.trustedHops) });

// request ids: a UUIDv7 per request (or the trusted proxy's X-Request-ID, src/request-id.ts). Fastify logs it as
// reqId, Yoga reuses it (it prefers req.id) for its own logs, and every response, /health and 413s too, echoes it.
app.addHook("onRequest", async (req, reply) => void reply.header(REQUEST_ID_HEADER, req.id));

// security-kit: security headers on every response, and request.security = { ip, userAgent, … } with the
// client IP taken from X-Forwarded-For only through the configured number of proxies (TRUST_PROXY).
Expand Down
17 changes: 17 additions & 0 deletions test/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,23 @@ async function fromConfig(url: string) {
ok((await sessionIp({ TRUST_PROXY: "1" })) === "203.0.113.9", "[config] TRUST_PROXY=1: the IP the load balancer saw, never the spoofed left entry");
ok((await sessionIp({})) === "127.0.0.1", "[config] no TRUST_PROXY: X-Forwarded-For is ignored");

// request ids: a UUIDv7 per request (never Fastify's "req-1" counter); X-Request-ID kept only from a trusted proxy
{
const UUID7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
const ids = async (env: Record<string, string>) => {
const c = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url, ...env }) });
const s = await buildServer(c);
const q = async (headers: Record<string, string> = {}) =>
String((await s.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...headers }, payload: JSON.stringify({ query: "{ health { ok } }" }) })).headers["x-request-id"]);
const out = { a: await q(), b: await q(), sent: await q({ "x-request-id": "0123456789abcdef0123456789abcdef" }), bad: await q({ "x-request-id": "evil\tid" }), health: String((await s.inject({ url: "/health?live=1" })).headers["x-request-id"]) };
await s.close();
return out;
};
const direct = await ids({}), proxied = await ids({ TRUST_PROXY: "1" });
ok(UUID7.test(direct.a) && UUID7.test(direct.b) && direct.a !== direct.b && UUID7.test(direct.health), "[config] x-request-id: a new UUIDv7 per request, on GraphQL and /health");
ok(UUID7.test(direct.sent) && proxied.sent === "0123456789abcdef0123456789abcdef" && UUID7.test(proxied.bad), "[config] x-request-id: the incoming one is kept only through TRUST_PROXY, and only when well-formed");
}

// TLS fingerprint: read from TLS_FINGERPRINT_HEADER only through trusted proxies, stored hashed, a change audited
const JA4 = "t13d1516h2_8daaf6152771_e5627efa2ab1", JA4b = "t13d1517h2_8daaf6152771_b1ff8ab2d16f";
{
Expand Down
Loading