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: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && tsc -p tsconfig.json",
"lint": "echo \"lint:api — covered by root eslint on staged files\"",
"test": "pnpm --filter @singleton-sd/post-kit-types run build && pnpm --filter @singleton-sd/post-kit-email run build && pnpm build && node --import tsx --test \"src/**/*.spec.ts\"",
"test": "pnpm build && node --import tsx --test \"src/**/*.spec.ts\"",
"start": "func start"
},
"dependencies": {
Expand Down
77 changes: 77 additions & 0 deletions apps/api/src/functions/contact.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import type { HttpRequest, InvocationContext } from '@azure/functions';
import { getContactRateLimiter } from '../contact-rate-limit';
import { contactHandler } from './contact';

function fakeRequest(options: {
method?: string;
headers?: Record<string, string>;
json?: unknown;
}): HttpRequest {
const headers = new Headers(options.headers);
return {
method: options.method ?? 'POST',
headers: {
get: (name: string) => headers.get(name),
},
json: async () => options.json ?? null,
} as unknown as HttpRequest;
}

function fakeContext(): InvocationContext & { errors: unknown[] } {
const errors: unknown[] = [];
return {
error: (...args: unknown[]) => {
errors.push(args);
},
errors,
} as unknown as InvocationContext & { errors: unknown[] };
}

describe('contactHandler', () => {
it('propagates a valid X-Correlation-Id on validation errors and includes it in the body', async () => {
getContactRateLimiter().reset();
const correlationId = 'client-corr-id-01';
const response = await contactHandler(
fakeRequest({
headers: { 'x-correlation-id': correlationId },
json: { name: 'bad' },
}),
fakeContext(),
);

assert.equal(response.status, 400);
assert.equal(response.headers?.['X-Correlation-Id'], correlationId);
assert.equal((response.jsonBody as { correlationId?: string }).correlationId, correlationId);
assert.equal(typeof (response.jsonBody as { error?: string }).error, 'string');
});

it('generates a correlation ID when the header is absent', async () => {
getContactRateLimiter().reset();
const response = await contactHandler(fakeRequest({ json: { name: 'bad' } }), fakeContext());

assert.equal(response.status, 400);
const headerId = response.headers?.['X-Correlation-Id'];
assert.equal(typeof headerId, 'string');
assert.match(headerId ?? '', /^[0-9a-f-]{36}$/i);
assert.equal((response.jsonBody as { correlationId?: string }).correlationId, headerId);
});

it('logs the request correlation ID on native error output, not the provider ID', async () => {
getContactRateLimiter().reset();
const correlationId = 'handler-corr-id-01';
const context = fakeContext();
await contactHandler(
fakeRequest({
headers: { 'x-correlation-id': correlationId },
json: { name: 'bad' },
}),
context,
);

const logged = context.errors[0] as [string, { correlationId?: string }];
assert.equal(logged[1]?.correlationId, correlationId);
assert.ok(!('emailCorrelationId' in (logged[1] ?? {}) && logged[1].emailCorrelationId));
});
});
70 changes: 59 additions & 11 deletions apps/api/src/functions/contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,74 +3,121 @@ import { EmailProviderError } from '@singleton-sd/post-kit-email';
import { ensureAppConfiguration } from '../config/app-configuration';
import { contactCorsHeaders, submitContactInquiry } from '../contact';
import { clientIpFromHeaders, getContactRateLimiter } from '../contact-rate-limit';
import { createLogger, resolveCorrelationId } from '../telemetry';

export async function contactHandler(
request: HttpRequest,
context: InvocationContext,
): Promise<HttpResponseInit> {
const startMs = Date.now();
const correlationId = resolveCorrelationId(request.headers.get('x-correlation-id') ?? undefined);
const logger = createLogger(correlationId);

logger.info('contact.request.received');

const origin = request.headers.get('origin');
try {
await ensureAppConfiguration();
} catch (error) {
const durationMs = Date.now() - startMs;
context.error('app configuration load failed', {
name: error instanceof Error ? error.name : 'Error',
});
logger.error('contact.request.failed', {
outcome: 'failed',
errorCode: 'configuration',
durationMs,
});
return {
status: 503,
headers: {
...contactCorsHeaders(origin),
'Content-Type': 'application/json',
'X-Correlation-Id': correlationId,
},
jsonBody: {
error: 'Contact delivery is temporarily unavailable. Please try again later.',
correlationId,
},
};
}
const cors = contactCorsHeaders(origin);

if (request.method === 'OPTIONS') {
return { status: 204, headers: cors };
return { status: 204, headers: { ...cors, 'X-Correlation-Id': correlationId } };
}

const ip = clientIpFromHeaders(request.headers);
const limit = getContactRateLimiter().tryConsume(ip);
if (!limit.allowed) {
const durationMs = Date.now() - startMs;
logger.error('contact.request.failed', {
outcome: 'failed',
errorCode: 'rate_limit',
durationMs,
});
return {
status: 429,
headers: {
...cors,
'Content-Type': 'application/json',
'Retry-After': String(limit.retryAfterSec),
'X-Correlation-Id': correlationId,
},
jsonBody: {
error: 'Too many messages were sent. Please wait a minute and try again.',
correlationId,
},
jsonBody: { error: 'Too many messages were sent. Please wait a minute and try again.' },
};
}

try {
const body = await request.json().catch(() => null);
const result = await submitContactInquiry(body, { requestOrigin: origin });
const durationMs = Date.now() - startMs;
logger.info('contact.request.completed', { outcome: 'sent', durationMs });
return {
status: 202,
headers: { ...cors, 'Content-Type': 'application/json' },
headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId },
jsonBody: result,
};
} catch (error) {
const durationMs = Date.now() - startMs;

const errorCode =
error instanceof EmailProviderError ? error.kind : (error as Error | undefined)?.name;

const statusFromValidation =
error instanceof Error && 'status' in error
? Number((error as Error & { status?: number }).status)
: undefined;

context.error('contact failed', {
name: error instanceof Error ? error.name : 'Error',
kind: error instanceof EmailProviderError ? error.kind : undefined,
statusCode: error instanceof EmailProviderError ? error.statusCode : undefined,
correlationId: error instanceof EmailProviderError ? error.correlationId : undefined,
correlationId,
emailCorrelationId: error instanceof EmailProviderError ? error.correlationId : undefined,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

logger.error('contact.request.failed', {
outcome: statusFromValidation === 400 ? 'validation_error' : 'failed',
errorCode,
durationMs,
});

const statusFromValidation =
error instanceof Error && 'status' in error
? Number((error as Error & { status?: number }).status)
: undefined;
if (statusFromValidation === 400) {
return {
status: 400,
headers: { ...cors, 'Content-Type': 'application/json' },
jsonBody: { error: error instanceof Error ? error.message : 'Invalid request' },
headers: {
...cors,
'Content-Type': 'application/json',
'X-Correlation-Id': correlationId,
},
jsonBody: {
error: error instanceof Error ? error.message : 'Invalid request',
correlationId,
},
};
}

Expand All @@ -80,11 +127,12 @@ export async function contactHandler(

return {
status: unavailable ? 503 : 500,
headers: { ...cors, 'Content-Type': 'application/json' },
headers: { ...cors, 'Content-Type': 'application/json', 'X-Correlation-Id': correlationId },
jsonBody: {
error: unavailable
? 'Contact delivery is temporarily unavailable. Please try again later.'
: 'We could not send your message. Please try again shortly.',
correlationId,
},
};
}
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/telemetry/correlation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { generateCorrelationId, resolveCorrelationId } from './correlation';

const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

describe('generateCorrelationId', () => {
it('returns a non-empty string matching UUID v4 pattern', () => {
const id = generateCorrelationId();
assert.ok(id.length > 0, 'should be non-empty');
assert.match(id, UUID_V4);
});

it('returns a different value on each call', () => {
assert.notEqual(generateCorrelationId(), generateCorrelationId());
});
});

describe('resolveCorrelationId', () => {
it('generates a fresh ID when headerValue is undefined', () => {
const id = resolveCorrelationId(undefined);
assert.ok(id.length > 0);
assert.match(id, UUID_V4);
});

it('returns a valid header value as-is', () => {
assert.equal(resolveCorrelationId('valid-id-123'), 'valid-id-123');
});

it('accepts UUIDs as valid header values', () => {
const uuid = generateCorrelationId();
assert.equal(resolveCorrelationId(uuid), uuid);
});

it('sanitises a value containing HTML/script injection characters', () => {
const id = resolveCorrelationId('<script>alert(1)</script>');
assert.notEqual(id, '<script>alert(1)</script>');
assert.match(id, UUID_V4);
});

it('generates a fresh ID when headerValue exceeds 128 chars', () => {
const id = resolveCorrelationId('a'.repeat(200));
assert.match(id, UUID_V4);
});

it('generates a fresh ID when headerValue is an empty string', () => {
const id = resolveCorrelationId('');
assert.match(id, UUID_V4);
});

it('generates a fresh ID when headerValue is fewer than 8 chars', () => {
const id = resolveCorrelationId('short');
assert.match(id, UUID_V4);
});

it('accepts an ID at the minimum boundary (8 chars)', () => {
assert.equal(resolveCorrelationId('abcd1234'), 'abcd1234');
});

it('accepts an ID at the maximum boundary (128 chars)', () => {
const max = 'a'.repeat(128);
assert.equal(resolveCorrelationId(max), max);
});
});
28 changes: 28 additions & 0 deletions apps/api/src/telemetry/correlation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Correlation ID utilities for per-request tracing.
*
* No external UUID library — Node 20 ships crypto.randomUUID() natively.
*/

/** Valid correlation ID: 8–128 alphanumeric / hyphen / underscore characters. */
const VALID_CORRELATION_ID = /^[a-zA-Z0-9_-]{8,128}$/;

/**
* Generate a new UUID v4 correlation ID.
*/
export function generateCorrelationId(): string {
return crypto.randomUUID();
}

/**
* Sanitise a caller-supplied X-Correlation-Id header value.
*
* Rules: must be 8–128 alphanumeric/hyphen/underscore characters only.
* If the value is missing, empty, or fails validation, a fresh ID is generated.
*/
export function resolveCorrelationId(headerValue: string | undefined): string {
if (headerValue && VALID_CORRELATION_ID.test(headerValue)) {
return headerValue;
}
return generateCorrelationId();
}
3 changes: 3 additions & 0 deletions apps/api/src/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { generateCorrelationId, resolveCorrelationId } from './correlation';
export { createLogger } from './logger';
export type { LogEntry, Logger } from './logger';
Loading
Loading