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
71 changes: 71 additions & 0 deletions backend/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { RedisClient } from '../shared/cache/types';
import { setPlanCacheService } from '../subscription/planCacheRegistry';
import { buildMetricsService } from '../services/shared/buildMetricsService';
import { rateLimitingService } from '../services/shared/rateLimitingService';
import { InMemorySpanExporter, Sampler, Tracer } from '../services/shared/tracing';

class FakeRedis implements RedisClient {
private store = new Map<string, string>();
Expand Down Expand Up @@ -515,3 +516,73 @@ describe('backend server', () => {
});
});
});

describe('backend server: conditional requests, versioning and request tracing', () => {
let running: Awaited<ReturnType<typeof startServer>>;
let port: number;
const exporter = new InMemorySpanExporter();

beforeEach(async () => {
exporter.reset();
const tracer = new Tracer({
serviceName: 'subtrackr-backend',
exporter,
sampler: new Sampler({ defaultRatio: 1, alwaysSampleErrors: true }),
});
running = await startServer({
pool: makeMockPool(),
planBootstrap: makeBootstrap(),
listen: false,
tracer,
});
port = await listenEphemeral(running.server);
jest.spyOn(console, 'log').mockImplementation(() => undefined);
});

afterEach(async () => {
jest.restoreAllMocks();
await running.shutdown();
setPlanCacheService(null);
});

it('emits an ETag and answers 304 to a matching If-None-Match (issue #1788)', async () => {
const first = await request(port, '/plans/plan-1');
const etag = first.headers['etag'];
expect(first.status).toBe(200);
expect(etag).toMatch(/^"[^"]+"$/);

const exact = await request(port, '/plans/plan-1', 'GET', undefined, { 'If-None-Match': etag });
expect(exact.status).toBe(304);
expect(exact.body).toBe('');

const list = await request(port, '/plans/plan-1', 'GET', undefined, {
'If-None-Match': `"stale", W/${etag}`,
});
expect(list.status).toBe(304);

const stale = await request(port, '/plans/plan-1', 'GET', undefined, { 'If-None-Match': '"stale"' });
expect(stale.status).toBe(200);
});

it('labels responses with the API version and rejects unknown versions (issue #1789)', async () => {
const ok = await request(port, '/health');
expect(ok.headers['api-version']).toBe('v1');
expect(ok.headers['deprecation']).toBeUndefined();

const unknown = await request(port, '/health', 'GET', undefined, { 'api-version': 'v9' });
expect(unknown.status).toBe(400);
expect(JSON.parse(unknown.body).error.code).toBe('VERSION_NOT_FOUND');
});

it('echoes the request id and exports a server span for it (issues #1790, #1791)', async () => {
const traceId = '4bf92f3577b34da6a3ce929d0e0e4736';
const res = await request(port, '/plans/plan-1', 'GET', undefined, {
'X-Request-Id': 'req-server-1',
traceparent: `00-${traceId}-00f067aa0ba902b7-01`,
});

expect(res.headers['x-request-id']).toBe('req-server-1');
const span = exporter.getFinishedSpans().find((s) => s.attributes['request.id'] === 'req-server-1');
expect(span).toMatchObject({ traceId, kind: 'server', name: 'GET /plans/plan-1' });
});
});
45 changes: 42 additions & 3 deletions backend/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ import {
} from './services/shared/ipWhitelistService';
import { serverSessionService } from './services/auth/serverSessionService';
import { buildMetricsService } from './services/shared/buildMetricsService';
import { createVersionMiddleware, versionRegistry } from './services/shared/apiVersioning';
import { createLoggerFor } from './services/shared/logging';
import { createTracerFromEnv, type Tracer } from './services/shared/tracing';
import { createRequestObservability } from './shared/middleware/requestObservability';

export interface StartServerOptions {
port?: number;
Expand All @@ -63,6 +67,8 @@ export interface StartServerOptions {
planBootstrap?: PlanCacheBootstrap;
/** When true, binds to port (default). Set false in tests. */
listen?: boolean;
/** Tracer for request spans. Defaults to one configured from OTEL_* env vars. */
tracer?: Tracer;
}

export interface RunningServer {
Expand Down Expand Up @@ -433,7 +439,13 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
{ origin: process.env['CORS_ALLOWED_ORIGIN'] ?? '*', isWildcard: true },
],
allowCredentials: false,
exposedHeaders: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'],
exposedHeaders: [
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'X-Request-Id',
'api-version',
],
maxAge: 86400,
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: [
Expand All @@ -446,7 +458,16 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
active: true,
});

const server = http.createServer(async (req, res) => {
const observeRequest = createRequestObservability({
tracer: options.tracer ?? createTracerFromEnv('subtrackr-backend'),
logger: createLoggerFor('http'),
});
const versionMiddleware = createVersionMiddleware(versionRegistry);

const handleRequest = async (
req: http.IncomingMessage,
res: http.ServerResponse,
): Promise<void> => {
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
const { pathname } = url;
const method = req.method ?? 'GET';
Expand Down Expand Up @@ -490,6 +511,20 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
return;
}

// -----------------------------------------------------------------
// API version (issue #1789): sets `api-version`, adds deprecation
// headers, and answers 400/410 for unknown or sunset versions.
// -----------------------------------------------------------------
let versionAccepted = false;
await versionMiddleware(
{ path: pathname, headers: req.headers, query: Object.fromEntries(url.searchParams) },
res,
() => {
versionAccepted = true;
},
);
if (!versionAccepted) return;

// -----------------------------------------------------------------
// IP Whitelist gate (issue #1158) — applied before all auth/rate-limit
// -----------------------------------------------------------------
Expand Down Expand Up @@ -1738,7 +1773,11 @@ export async function startServer(options: StartServerOptions = {}): Promise<Run
console.error('[Server] Request error:', err);
sendJson(res, 500, { error: 'Internal server error' });
}
});
};

const server = http.createServer((req, res) =>
observeRequest(req, res, () => handleRequest(req, res)),
);

const port = options.port ?? Number(process.env.PORT ?? 3001);
const host = options.host ?? process.env.HOST ?? '0.0.0.0';
Expand Down
15 changes: 15 additions & 0 deletions backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import {
createThemeRouter,
} from '../subscription/router';
import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse';
import { createVersionMiddleware, versionRegistry } from '../services/shared/apiVersioning';
import { createLoggerFor } from '../services/shared/logging';
import { createTracerFromEnv } from '../services/shared/tracing';
import { createRequestObservability } from '../shared/middleware/requestObservability';
import { createApiKeyRevocationRouter, createPasskeyRouter } from '../services/auth/router/authRouter';

// ── Zapier integration ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -156,6 +160,14 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {

app.disable('x-powered-by');

// Request id, structured access log and server span for every request.
app.use(
createRequestObservability({
tracer: createTracerFromEnv('subtrackr-api'),
logger: createLoggerFor('http'),
}),
);

// Provider webhooks are signed over the exact bytes that were sent, and
// `express.json()` consumes the request stream. `express.raw` is therefore
// registered for the webhook paths *before* the JSON parser, so
Expand Down Expand Up @@ -193,6 +205,9 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {
res.setHeader(API_VERSION_HEADER, API_VERSION_VALUE);
next();
});
// Resolves the version from /api/vN, the `api-version` header or `?version=`;
// adds deprecation headers and rejects unknown or sunset versions.
app.use(createVersionMiddleware(versionRegistry) as unknown as express.RequestHandler);

// ── CDN-cached public routes ───────────────────────────────────────────────
app.use(cacheHeadersMiddleware());
Expand Down
16 changes: 6 additions & 10 deletions backend/services/shared/__tests__/apiVersioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,21 +750,17 @@ describe('createVersionMiddleware — version resolution order', () => {
// ─── Singleton versionRegistry ────────────────────────────────────────────────

describe('versionRegistry singleton', () => {
it('is exported and has v1 and v2 registered', () => {
it('registers v1, the version every route is served under', () => {
expect(versionRegistry.has('v1')).toBe(true);
expect(versionRegistry.has('v2')).toBe(true);
});

it('default version is v2', () => {
expect(versionRegistry.getDefault()).toBe('v2');
it('defaults to v1', () => {
expect(versionRegistry.getDefault()).toBe('v1');
});

it('v1 is deprecated', () => {
expect(versionRegistry.get('v1')?.lifecycle).toBe('deprecated');
});

it('v2 is active', () => {
expect(versionRegistry.get('v2')?.lifecycle).toBe('active');
it('keeps v1 active so clients get no deprecation headers', () => {
expect(versionRegistry.get('v1')?.lifecycle).toBe('active');
expect(versionRegistry.getDeprecationWarning('v1')).toBeNull();
});
});

Expand Down
15 changes: 15 additions & 0 deletions backend/services/shared/__tests__/compression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,21 @@ describe('isETagMatch', () => {
const req = mockReq({ headers: {} });
expect(isETagMatch(req, '"abc"')).toBe(false);
});

it('matches any tag in a comma-separated list', () => {
const req = mockReq({ headers: { 'if-none-match': '"old", "abc123" , "other"' } });
expect(isETagMatch(req, '"abc123"')).toBe(true);
});

it('uses weak comparison so a W/ prefix still matches', () => {
const req = mockReq({ headers: { 'if-none-match': 'W/"abc123"' } });
expect(isETagMatch(req, '"abc123"')).toBe(true);
});

it('does not match a tag that only shares a prefix', () => {
const req = mockReq({ headers: { 'if-none-match': '"abc"' } });
expect(isETagMatch(req, '"abc123"')).toBe(false);
});
});

// ─── applyCompression — core behaviour ───────────────────────────────────────
Expand Down
29 changes: 15 additions & 14 deletions backend/services/shared/apiVersioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,13 +376,18 @@ export function resolveVersion(
options: VersionExtractionOptions = {},
): VersionResolution | null {
const defaultVersion = registry.getDefault();
// An explicitly requested version that is not registered is rejected rather
// than silently served as the default, so clients notice a typo or a
// version that never existed.
let requestedUnknown = false;

// 1. Path
if (opts.path && options.fromPath !== false) {
const v = extractVersionFromPath(opts.path);
if (v && registry.has(v)) {
return { version: v, source: 'path', config: registry.get(v)! };
}
if (v) requestedUnknown = true;
}

// 2. Header
Expand All @@ -391,6 +396,7 @@ export function resolveVersion(
if (v && registry.has(v)) {
return { version: v, source: 'header', config: registry.get(v)! };
}
if (v) requestedUnknown = true;
}

// 3. Query param
Expand All @@ -399,8 +405,11 @@ export function resolveVersion(
if (v && registry.has(v)) {
return { version: v, source: 'query', config: registry.get(v)! };
}
if (v) requestedUnknown = true;
}

if (requestedUnknown) return null;

// 4. Default
if (defaultVersion && registry.has(defaultVersion)) {
return { version: defaultVersion, source: 'default', config: registry.get(defaultVersion)! };
Expand Down Expand Up @@ -586,26 +595,18 @@ export function createVersionMiddleware(
// ─── Singleton registry ───────────────────────────────────────────────────────

/**
* Application-wide version registry.
* Pre-configured with SubTrackr's version history.
* Application-wide version registry. Mirrors what is actually served: every
* route is v1 (`/api/v1/*`, `X-API-Version: 1`). Register v2 here as `draft`
* when its first breaking change lands; see docs/api-versioning.md for the
* deprecation policy.
*/
export const versionRegistry = new ApiVersionRegistry();

versionRegistry
.register({
version: 'v1',
lifecycle: 'deprecated',
releasedAt: '2024-01-01T00:00:00Z',
deprecatedAt: '2025-01-01T00:00:00Z',
sunsetAt: '2026-06-01T00:00:00Z',
successorVersion: 'v2',
migrationUrl: 'https://docs.subtrackr.io/migration/v1-to-v2',
description: 'Initial release. Deprecated in favour of v2.',
})
.register({
version: 'v2',
lifecycle: 'active',
releasedAt: '2025-01-01T00:00:00Z',
releasedAt: '2024-01-01T00:00:00Z',
description: 'Current stable API version.',
})
.setDefault('v2');
.setDefault('v1');
13 changes: 11 additions & 2 deletions backend/services/shared/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,19 @@ export function generateETag(body: Buffer): string {
return `"${hash.slice(0, 27)}"`;
}

/**
* If-None-Match uses the weak comparison (RFC 9110 §13.1.2): the header may
* list several tags, and a `W/` prefix on either side is ignored.
*/
export function isETagMatch(req: IncomingMessage, etag: string): boolean {
const ifNoneMatch = req.headers['if-none-match'];
const raw = req.headers['if-none-match'];
const ifNoneMatch = Array.isArray(raw) ? raw.join(',') : raw;
if (!ifNoneMatch) return false;
return ifNoneMatch === etag || ifNoneMatch === '*';
if (ifNoneMatch.trim() === '*') return true;

const opaque = (tag: string) => tag.trim().replace(/^W\//, '');
const target = opaque(etag);
return ifNoneMatch.split(',').some((candidate) => opaque(candidate) === target);
}

// ─── Compression ─────────────────────────────────────────────────────────────
Expand Down
Loading
Loading