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
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions src/audit/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -59,7 +61,7 @@ type AuditEvent =
| "webhook.deleted"
| "webhook.secret_rotated";

interface AuditFields {
export interface AuditFields {
userId?: string;
submissionId?: string;
credentialId?: string;
Expand Down
26 changes: 26 additions & 0 deletions src/database/migrations/0006_add_recommendation_indexes.sql
Original file line number Diff line number Diff line change
@@ -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);
34 changes: 34 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
*/
export async function authGuard(
request: FastifyRequest,
reply: FastifyReply,

Check warning on line 41 in src/middleware/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Typecheck

'reply' is defined but never used. Allowed unused args must match /^_/u
): Promise<void> {
let decoded: { sub: string; stellarAddress: string; jti?: string };
try {
Expand Down Expand Up @@ -144,3 +144,37 @@
export interface AuthenticatedRequest extends FastifyRequest {
authUser: AuthUser;
}

/**
* Admin guard — verifies the request carries the static ADMIN_API_KEY in the
* Authorization header as `Bearer <key>`. 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<void> {
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");
}
}
27 changes: 27 additions & 0 deletions src/modules/admin/admin-users.types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof userIdParamsSchema>;
export type CreditAdjustmentBody = z.infer<typeof creditAdjustmentSchema>;

export interface CreditAdjustmentResult {
userId: string;
previousCredits: number;
newCredits: number;
delta: number;
}
3 changes: 3 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
18 changes: 11 additions & 7 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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);

Expand Down Expand Up @@ -402,6 +405,7 @@ async function start() {
clearInterval(cacheWarmInterval);
}
await app.close();
await stopAuditLogger();
await closeDatabase();
await closeRedis();
await shutdownTracing();
Expand Down
Loading