diff --git a/README.md b/README.md index 3c3a4c8..b76a778 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,39 @@ The API will be available at `http://localhost:3000`. ### Health +The API exposes three health check endpoints with distinct responsibilities: + | Method | Path | Description | |--------|------|-------------| -| `GET` | `/health` | Health check | +| `GET` | `/health/live` | **Liveness** — always returns `200 { status: "ok" }`. Wire to Kubernetes `livenessProbe` or any "is the process alive?" check. A failing liveness probe triggers a container restart. | +| `GET` | `/health/ready` | **Readiness** — returns `200` when PostgreSQL and Redis are reachable, `503` otherwise. Wire to Kubernetes `readinessProbe` and load balancer health gates. Stellar is deliberately excluded: a blockchain outage must not remove healthy API instances from rotation, since course browsing, quiz taking, and other non-Stellar features continue to work. | +| `GET` | `/health` | **Full health** — checks all four dependencies (DB, Redis, Stellar Horizon, Soroban RPC). Returns `200 healthy` or `503 degraded`. Intended for monitoring dashboards and alerting only — **do not** wire this to probes that restart containers or pull instances from the load balancer. | + +**Example readiness response (healthy):** +```json +{ + "status": "ready", + "checks": { + "database": "ok", + "redis": "ok" + } +} +``` + +**Example full health response (Soroban degraded):** +```json +{ + "status": "degraded", + "timestamp": "2026-09-30T12:00:00.000Z", + "uptime": 3600, + "checks": { + "database": "ok", + "redis": "ok", + "stellar_horizon": "ok", + "stellar_soroban": "error" + } +} +``` ## Database Schema diff --git a/src/audit/index.ts b/src/audit/index.ts index 57ac6e8..bdd42b5 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -3,7 +3,9 @@ import { db } from "../config/database.js"; import { auditLogs } from "../database/schema.js"; import { getRequestId } from "../utils/request-context.js"; -type AuditEvent = +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type AuditEvent = | "quiz.submitted" | "quiz.retried" | "reward.claimed" @@ -59,7 +61,7 @@ type AuditEvent = | "webhook.deleted" | "webhook.secret_rotated"; -interface AuditFields { +export interface AuditFields { userId?: string; submissionId?: string; credentialId?: string; diff --git a/src/database/migrations/0006_add_recommendation_indexes.sql b/src/database/migrations/0006_add_recommendation_indexes.sql new file mode 100644 index 0000000..791a320 --- /dev/null +++ b/src/database/migrations/0006_add_recommendation_indexes.sql @@ -0,0 +1,26 @@ +-- Indexes to support the getRecommendedCourses query pattern. +-- +-- Query 1 (user context): fetches all of a user's enrollments with +-- completed_at and joins to credentials. The existing unique index +-- idx_enrollments_user_course covers (user_id, course_id) but does not +-- include completed_at, so a partial scan is needed to filter completed rows. +-- This composite index lets the planner satisfy +-- WHERE user_id = ? +-- and cover completed_at without a heap fetch. +CREATE INDEX IF NOT EXISTS idx_enrollments_user_completed + ON enrollments (user_id, completed_at); + +-- Query 2 (peer collaborative filtering): finds peers who share any of the +-- current user's enrolled courses, then aggregates their other enrollments. +-- The join condition is WHERE course_id = ANY(?) which requires an index +-- on course_id alone. The leading-column of the unique index is user_id, +-- so it is not used for course-first lookups on all planner configurations. +CREATE INDEX IF NOT EXISTS idx_enrollments_course_id + ON enrollments (course_id); + +-- Query 3 (candidate courses): every recommendation query filters by +-- is_active = true and optionally by difficulty. A composite covering index +-- lets the planner satisfy both predicates without visiting the table heap +-- for the filter pass. +CREATE INDEX IF NOT EXISTS idx_courses_active_difficulty + ON courses (is_active, difficulty); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index a972520..8fb2eeb 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -144,3 +144,37 @@ export interface AuthUser { export interface AuthenticatedRequest extends FastifyRequest { authUser: AuthUser; } + +/** + * Admin guard — verifies the request carries the static ADMIN_API_KEY in the + * Authorization header as `Bearer `. Intentionally separate from the + * user JWT flow so admin credentials can be rotated independently. + * + * Timing-safe comparison via `crypto.timingSafeEqual` prevents timing attacks + * that could be used to brute-force the key character-by-character. + */ +import crypto from "node:crypto"; +import { config } from "../config/index.js"; + +export async function adminGuard( + request: FastifyRequest, + _reply: FastifyReply, +): Promise { + const authHeader = request.headers.authorization ?? ""; + const token = authHeader.startsWith("Bearer ") + ? authHeader.slice(7) + : ""; + + // Always run the comparison even when token is empty to prevent early-exit + // timing differences from leaking whether the key exists. + const expected = Buffer.from(config.ADMIN_API_KEY, "utf8"); + const provided = Buffer.from(token, "utf8"); + + const valid = + provided.length === expected.length && + crypto.timingSafeEqual(provided, expected); + + if (!valid) { + throw new UnauthorizedError("Invalid or missing admin API key"); + } +} diff --git a/src/modules/admin/admin-users.types.ts b/src/modules/admin/admin-users.types.ts new file mode 100644 index 0000000..b13e727 --- /dev/null +++ b/src/modules/admin/admin-users.types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; + +// ─── Request schemas ────────────────────────────────────────────────────────── + +export const userIdParamsSchema = z.object({ + userId: z.string().uuid("Invalid user ID"), +}); + +export const creditAdjustmentSchema = z.object({ + amount: z + .number() + .int("Amount must be an integer") + .positive("Amount must be greater than zero"), + reason: z.string().min(1).max(255), +}); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type UserIdParams = z.infer; +export type CreditAdjustmentBody = z.infer; + +export interface CreditAdjustmentResult { + userId: string; + previousCredits: number; + newCredits: number; + delta: number; +} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 527b069..5b31390 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -7,6 +7,7 @@ import { getNetworkPassphrase } from "../../config/stellar.js"; import { RateLimitError, UnauthorizedError } from "../../utils/errors.js"; import { logger } from "../../utils/logger.js"; import { eq } from "drizzle-orm"; +import { auditLog } from "../../audit/index.js"; import type { ChallengeResponse, AuthResponse } from "./auth.types.js"; import { checkAuthLockout, @@ -281,6 +282,8 @@ export class AuthService { logger.info({ stellarAddress, userId: user.id }, "New user created"); } + auditLog("auth.login", { userId: user.id, stellarAddress }); + return { token: "", // Will be set by controller user: { diff --git a/src/server.ts b/src/server.ts index e1395e6..d635404 100644 --- a/src/server.ts +++ b/src/server.ts @@ -220,12 +220,8 @@ async function buildApp() { (c) => c.status === "fulfilled", ); - const status = allHealthy ? "healthy" : "degraded"; - - return reply.status(allHealthy ? 200 : 503).send({ - status, - timestamp: new Date().toISOString(), - uptime: process.uptime(), + return reply.status(ready ? 200 : 503).send({ + status: ready ? "ready" : "not_ready", checks: { database: dbCheck.status === "fulfilled" ? "ok" : "error", redis: redisCheck.status === "fulfilled" ? "ok" : "error", @@ -336,7 +332,9 @@ async function buildApp() { ); return reply.status(allHealthy ? 200 : 503).send({ - status: allHealthy ? "ready" : "not_ready", + status: allHealthy ? "healthy" : "degraded", + timestamp: new Date().toISOString(), + uptime: process.uptime(), checks: { database: dbCheck.status === "fulfilled" ? "ok" : "error", redis: redisCheck.status === "fulfilled" ? "ok" : "error", @@ -346,6 +344,11 @@ async function buildApp() { }); }); + app.get("/metrics", { preHandler: authGuard }, async (_request, reply) => { + reply.header("Content-Type", registry.contentType); + return reply.send(await registry.metrics()); + }); + // ─── API Routes ───────────────────────────────────────────────────────── await registerVersionedRoutes(app); @@ -402,6 +405,7 @@ async function start() { clearInterval(cacheWarmInterval); } await app.close(); + await stopAuditLogger(); await closeDatabase(); await closeRedis(); await shutdownTracing();