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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
- `docs/API.md`
- `docs/CONTRIBUTING.md`
- `docs/I18N.md`
- `docs/OBSERVABILITY.md`
- `docs/REDIRECTS.md`
- `docs/SCRIPTS.md`
- `docs/TECHNOLOGY.md`
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Added structured server observability primitives: request IDs (`x-request-id`), request lifecycle logging middleware, and auth audit log hooks.
- Added `docs/OBSERVABILITY.md` to document default logging/audit patterns.
- Added shared `LoadingFallback` and `BackHomeCta` UI components to reduce repeated loading/CTA markup.
- Added `skills/migrate-design-system-to-shadcn/SKILL.md` to guide full Chakra-to-shadcn migration with explicit removal criteria.
- Added `skills/add-form-manager/SKILL.md` to standardize React Hook Form + Zod adoption, starting with login-form migration guidance.
Expand Down
8 changes: 8 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Browser
Express Server (vite-express)
├── Auth Routes (/api/session + legacy /login/password, /logout) → Passport.js LocalStrategy
├── API Routes (/api/key) → Returns encryption key
├── Request ID + Structured Logging Middleware
├── Redirect Rules (legacy paths) → Config-driven HTTP redirects
├── Page Routes (/, /login, /product, etc.) → Served by Vite
└── Static Routes (/sitemap.xml)
Expand Down Expand Up @@ -74,6 +75,13 @@ src/
- Feature-level latency should use local `Suspense` only for independently-loading sections, not whole-page wrappers.
- Use React 19 `Activity` around loading fallback UI when representing active pending work.

### Observability

- Every request gets an `x-request-id` (propagated if provided, generated otherwise).
- Structured JSON request logs capture method/path/status/duration/requestId.
- Error logs include request context via global error middleware.
- Auth flows emit audit hooks (login/session/logout/rate-limit events).

### Client/Server Code Separation

The client uses `src/client/utilities/` for browser-specific helpers. Server helpers live in `src/server/services/` and `src/server/config/`. Keep client-only and server-only modules separated to avoid accidental cross-runtime imports.
Expand Down
2 changes: 1 addition & 1 deletion docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,6 @@ For production user management, move away from hardcoded credentials.

- Store secrets in env/secret manager (never in repo).
- Rotate `SESSION_SECRET` per environment.
- Add brute-force protection and audit logging.
- Keep brute-force protection and extend audit logging hooks for new auth events.
- Expand tests for login/logout/protected-route behavior.
- Add authorization roles/claims if admin capabilities expand.
43 changes: 43 additions & 0 deletions docs/OBSERVABILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Observability

This boilerplate includes structured server logging from day one.

## What is included

- Request ID attachment middleware (`x-request-id`)
- Request completion logs with duration/status
- Global error logs with request context
- Auth-focused audit event hooks (login/logout/session/rate-limit events)

## Request ID behavior

- Incoming `x-request-id` is preserved when provided.
- Otherwise, the server generates a UUID.
- The response always includes `x-request-id`.

## Log format

Logs are JSON lines with common fields:

- `timestamp`
- `level`
- `event`
- request context fields (`requestId`, `method`, `path`, `statusCode`, etc.)

This makes logs easy to ingest in platforms like Datadog, Elastic, or CloudWatch.

## Auth audit hooks

Audit events are emitted for:

- legacy login success/failure
- REST session create success/failure
- session delete
- legacy logout
- login rate-limit trigger

## Extension guidance

- Add domain-specific audit events near critical state transitions.
- Keep event names stable and machine-friendly (for dashboards/alerts).
- Avoid logging secrets, tokens, passwords, or raw personal data.
5 changes: 3 additions & 2 deletions src/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Follows an MVC-style layout:
- **routes/** — Route definitions. Auth routes (REST session endpoints + legacy login/logout compatibility), API routes (e.g. `/api/key`), page routes (redirect middleware + pass-through to Vite or serve sitemap).
- **controllers/** — Request handlers. Call services and send responses.
- **services/** — Business logic (auth verification, JWT, etc.).
- **middleware/** — Auth (`ensureAuthenticated`), CSRF, global error handler.
- **middleware/** — Auth (`ensureAuthenticated`), CSRF, request-id/logging, global error handler.
- **config/** — Constants, API paths, session config.

## Key Flows
Expand All @@ -24,5 +24,6 @@ Follows an MVC-style layout:
- **API key:** Authenticated GET `/api/key` returns encryption key for client-side localStorage.
- **Pages:** Most routes pass through to Vite; protected routes use `ensureAuthenticated` before passing.
- **Redirects:** Legacy URLs are handled by config-driven rules in `src/server/config/redirects.ts` before page rendering. See [docs/REDIRECTS.md](../../docs/REDIRECTS.md).
- **Observability:** Structured request/error/audit logs with request IDs are enabled by default.

See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the full system diagram, [docs/AUTHENTICATION.md](../../docs/AUTHENTICATION.md) for auth details, and [docs/API.md](../../docs/API.md) for API contract conventions.
See [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the full system diagram, [docs/AUTHENTICATION.md](../../docs/AUTHENTICATION.md) for auth details, [docs/API.md](../../docs/API.md) for API contract conventions, and [docs/OBSERVABILITY.md](../../docs/OBSERVABILITY.md) for logging/audit patterns.
1 change: 1 addition & 0 deletions src/server/config/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const REQUEST_ID_HEADER = 'x-request-id';
15 changes: 13 additions & 2 deletions src/server/controllers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RequestHandler, CookieOptions } from 'express';
import passport from 'passport';
import { ROUTES } from '../config/constants';
import { signToken } from '../services/jwt';
import { writeAuditEvent } from '../services/audit-log';

interface AuthUser {
name: string;
Expand Down Expand Up @@ -57,9 +58,13 @@ export const postLogin: RequestHandler = (req, res, next) => {
next,
(user) => {
createSession(res, user);
writeAuditEvent('auth.legacy.login.success', req, res, {
email: user.email,
});
res.redirect(ROUTES.PRODUCT);
},
() => {
writeAuditEvent('auth.legacy.login.failure', req, res);
res.redirect(ROUTES.LOGIN);
}
);
Expand All @@ -72,6 +77,9 @@ export const postSession: RequestHandler = (req, res, next) => {
next,
(user) => {
createSession(res, user);
writeAuditEvent('auth.session.create.success', req, res, {
email: user.email,
});
res.status(201).json({
user: {
email: user.email,
Expand All @@ -80,6 +88,7 @@ export const postSession: RequestHandler = (req, res, next) => {
});
},
() => {
writeAuditEvent('auth.session.create.failure', req, res);
res.status(401).json({
code: 'INVALID_CREDENTIALS',
message: 'Incorrect username or password.',
Expand All @@ -89,12 +98,14 @@ export const postSession: RequestHandler = (req, res, next) => {
);
};

export const deleteSession: RequestHandler = (_req, res) => {
export const deleteSession: RequestHandler = (req, res) => {
writeAuditEvent('auth.session.delete', req, res);
res.clearCookie(TOKEN_COOKIE_NAME, getCookieOptions());
res.status(204).send();
};

export const getLogout: RequestHandler = (_req, res) => {
export const getLogout: RequestHandler = (req, res) => {
writeAuditEvent('auth.legacy.logout', req, res);
res.clearCookie(TOKEN_COOKIE_NAME, getCookieOptions());
res.redirect(ROUTES.HOME);
};
13 changes: 9 additions & 4 deletions src/server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { fileURLToPath } from 'url';
import path from 'path';
import { csrfProtection } from './middleware/csrf';
import { globalErrorHandler } from './middleware/error-handler';
import { attachRequestId } from './middleware/request-id';
import { requestLogger } from './middleware/request-logger';
import authRoutes from './routes/auth';
import apiRoutes from './routes/api';
import pageRoutes from './routes/pages';
import { logInfo } from './utilities/logger';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -19,6 +22,8 @@ const setupMiddleware = (app: express.Application) => {
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cookieParser());
app.use(attachRequestId);
app.use(requestLogger);
};

const setupRoutes = (app: express.Application) => {
Expand All @@ -41,10 +46,10 @@ const startServer = () => {
}).format(port);

ViteExpress.listen(app, port, () => {
// eslint-disable-next-line no-console
console.log(
`${process.env.NODE_ENV ?? ''} Server is listening on ${displayPort}.`
);
logInfo('server.started', {
environment: process.env.NODE_ENV ?? 'development',
port: displayPort,
});
});
};

Expand Down
9 changes: 8 additions & 1 deletion src/server/middleware/auth-rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import rateLimit from 'express-rate-limit';
import { ROUTES } from '../config/constants';
import { getRequestId, logWarn } from '../utilities/logger';

const DEFAULT_LOGIN_WINDOW_MS = 15 * 60 * 1000;
const DEFAULT_LOGIN_MAX_ATTEMPTS = 5;
Expand Down Expand Up @@ -42,10 +43,16 @@ export const loginRateLimiter = rateLimit({
requestWasSuccessful: (_req, res) => {
return res.statusCode === 302 && res.getHeader('location') === ROUTES.PRODUCT;
},
handler: (_req, res, _next, options) => {
handler: (req, res, _next, options) => {
const message = typeof options.message === 'string'
? options.message
: TOO_MANY_ATTEMPTS_MSG;
logWarn('auth.rate_limit.triggered', {
method: req.method,
path: req.path,
requestId: getRequestId(req, res),
statusCode: options.statusCode,
});
res.status(options.statusCode).send(message);
},
message: TOO_MANY_ATTEMPTS_MSG,
Expand Down
11 changes: 10 additions & 1 deletion src/server/middleware/error-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction } from 'express';
import { ROUTES, API_PREFIX } from '../config/constants';
import { type ApiErrorResponse, API_ERRORS } from '../config/api-error';
import { getRequestId, logError } from '../utilities/logger';

/**
* Global error handler middleware.
Expand All @@ -20,8 +21,16 @@ export const globalErrorHandler = (
_next: NextFunction, // eslint-disable-line @typescript-eslint/no-unused-vars
) => {
const { status, message } = err;
const requestId = getRequestId(req, res);

console.error(`[${req.method}] ${req.path} — ${message}`);
logError('http.request.error', {
code: err.code,
message,
method: req.method,
path: req.path,
requestId,
status,
});

// API routes get structured JSON responses that clients can parse
if (req.path.startsWith(`${API_PREFIX}/`)) {
Expand Down
13 changes: 13 additions & 0 deletions src/server/middleware/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import crypto from 'crypto';
import type { RequestHandler } from 'express';
import { REQUEST_ID_HEADER } from '../config/logging';

export const attachRequestId: RequestHandler = (req, res, next) => {
const incomingId = req.header(REQUEST_ID_HEADER);
const requestId = incomingId && incomingId.trim().length > 0
? incomingId
: crypto.randomUUID();

res.setHeader(REQUEST_ID_HEADER, requestId);
next();
};
19 changes: 19 additions & 0 deletions src/server/middleware/request-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { RequestHandler } from 'express';
import { getRequestId, logInfo } from '../utilities/logger';

export const requestLogger: RequestHandler = (req, res, next) => {
const start = performance.now();

res.on('finish', () => {
const durationMs = Math.round(performance.now() - start);
logInfo('http.request.completed', {
durationMs,
method: req.method,
path: req.path,
requestId: getRequestId(req, res),
statusCode: res.statusCode,
});
});

next();
};
21 changes: 21 additions & 0 deletions src/server/services/audit-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Request, Response } from 'express';
import { getRequestId, logInfo } from '../utilities/logger';

type AuditFields = Record<string, unknown>;

export const writeAuditEvent = (
event: string,
req: Request,
res: Response,
fields: AuditFields = {}
) => {
logInfo('audit.event', {
...fields,
event,
ip: req.ip ?? 'unknown-ip',
method: req.method,
path: req.path,
requestId: getRequestId(req, res),
userAgent: req.get('user-agent') ?? 'unknown-user-agent',
});
};
59 changes: 59 additions & 0 deletions src/server/utilities/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Request, Response } from 'express';
import { REQUEST_ID_HEADER } from '../config/logging';

type LogLevel = 'info' | 'warn' | 'error';

interface LogEntry {
level: LogLevel;
timestamp: string;
event: string;
[key: string]: unknown;
}

const writeLog = (entry: LogEntry) => {
const line = `${JSON.stringify(entry)}\n`;

if (entry.level === 'error') {
process.stderr.write(line);
return;
}

process.stdout.write(line);
};

export const logInfo = (event: string, fields: Record<string, unknown> = {}) => {
writeLog({
level: 'info',
timestamp: new Date().toISOString(),
event,
...fields,
});
};

export const logWarn = (event: string, fields: Record<string, unknown> = {}) => {
writeLog({
level: 'warn',
timestamp: new Date().toISOString(),
event,
...fields,
});
};

export const logError = (event: string, fields: Record<string, unknown> = {}) => {
writeLog({
level: 'error',
timestamp: new Date().toISOString(),
event,
...fields,
});
};

export const getRequestId = (req: Request, res: Response): string => {
const responseHeader = res.getHeader(REQUEST_ID_HEADER);
if (typeof responseHeader === 'string') {
return responseHeader;
}

const requestHeader = req.header(REQUEST_ID_HEADER);
return requestHeader ?? 'unknown-request-id';
};