From 242002d0fe13b01477f740393110e16bc3806a27 Mon Sep 17 00:00:00 2001 From: 0takuc0mrade Date: Sun, 30 Aug 2026 20:48:33 +0100 Subject: [PATCH] fix: resolve assigned API discovery and tracing issues --- package-lock.json | 329 +++++++++++++++++- package.json | 6 +- src/audit/index.ts | 9 +- .../0010_users_stellar_address_index.sql | 2 + src/database/schema.ts | 3 +- src/modules/courses/course.routes.ts | 12 + src/modules/courses/course.service.ts | 12 +- src/modules/courses/course.types.ts | 1 + src/modules/credentials/credential.routes.ts | 10 + src/modules/quizzes/ai-client.ts | 9 +- src/modules/quizzes/quiz.routes.ts | 29 ++ src/modules/rewards/reward.routes.ts | 16 + src/modules/users/user.routes.ts | 13 + src/server.ts | 36 +- src/stellar/client.ts | 9 +- src/utils/request-context.ts | 14 + tests/e2e/docs.test.ts | 33 ++ tests/unit/quizzes/ai-client.test.ts | 23 ++ tests/unit/schemas/input-validation.test.ts | 11 + 19 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 src/database/migrations/0010_users_stellar_address_index.sql create mode 100644 src/utils/request-context.ts create mode 100644 tests/e2e/docs.test.ts diff --git a/package-lock.json b/package-lock.json index 6ecad32..e254208 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,8 @@ "@fastify/helmet": "^13.0.1", "@fastify/jwt": "^9.0.2", "@fastify/rate-limit": "^10.2.0", + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^5.2.6", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-fastify": "^0.56.0", @@ -1154,6 +1156,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fastify/accept-negotiator": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.1.0.tgz", + "integrity": "sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/ajv-compiler": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", @@ -1387,6 +1405,209 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/send": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@fastify/send/-/send-4.1.1.tgz", + "integrity": "sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@lukeed/ms": "^2.0.2", + "escape-html": "~1.0.3", + "fast-decode-uri-component": "^1.0.1", + "http-errors": "^2.0.0", + "mime": "^3" + } + }, + "node_modules/@fastify/static": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.3.0.tgz", + "integrity": "sha512-9YMYRpCOtMBrqKYWcqiw7ykOrn4D0jogHpJrFS0KGeSuOwzKMM5/mjj7B0CFLVoQ6htqKYw//Zs7APn9DBq05w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "@fastify/send": "^4.0.0", + "content-disposition": "^1.0.1", + "fastify-plugin": "^6.0.0", + "fastq": "^1.17.1", + "glob": "^13.0.0" + } + }, + "node_modules/@fastify/static/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/static/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@fastify/static/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/static/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@fastify/swagger": { + "version": "9.8.1", + "resolved": "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.8.1.tgz", + "integrity": "sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^6.0.0", + "json-schema-resolver": "^3.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.2" + } + }, + "node_modules/@fastify/swagger-ui": { + "version": "5.2.6", + "resolved": "https://registry.npmjs.org/@fastify/swagger-ui/-/swagger-ui-5.2.6.tgz", + "integrity": "sha512-OMnms0O5s9wb6wis/K5nlrAMLsgUbr1GA8uphM41IasWe3AFdgxz6r/3bA9HTxlDNUYc2FGGKeqMp3ntxmSiNA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/static": "^9.1.2", + "fastify-plugin": "^5.0.0", + "openapi-types": "^12.1.3", + "rfdc": "^1.3.1", + "yaml": "^2.4.1" + } + }, + "node_modules/@fastify/swagger/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@grpc/grpc-js": { "version": "1.14.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", @@ -4071,6 +4292,19 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -4193,6 +4427,15 @@ "node": ">=0.10" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -4607,6 +4850,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -5506,6 +5755,26 @@ "entities": "^7.0.1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -5854,6 +6123,23 @@ "dequal": "^2.0.3" } }, + "node_modules/json-schema-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-resolver/-/json-schema-resolver-3.0.0.tgz", + "integrity": "sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fast-uri": "^3.0.5", + "rfdc": "^1.1.4" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/Eomm/json-schema-resolver?sponsor=1" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6034,6 +6320,18 @@ "node": ">= 0.4" } }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -6088,7 +6386,6 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -6165,6 +6462,12 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -6965,6 +7268,12 @@ "node": ">= 0.4" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/sha.js": { "version": "2.4.12", "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", @@ -7112,6 +7421,15 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -7390,6 +7708,15 @@ "node": ">=20" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/toml": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", diff --git a/package.json b/package.json index f445578..76f089c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@fastify/helmet": "^13.0.1", "@fastify/jwt": "^9.0.2", "@fastify/rate-limit": "^10.2.0", + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^5.2.6", "@opentelemetry/api": "^1.9.1", "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", "@opentelemetry/instrumentation-fastify": "^0.56.0", @@ -50,13 +52,13 @@ "@types/sanitize-html": "^2.16.1", "@typescript-eslint/eslint-plugin": "^8.61.1", "@typescript-eslint/parser": "^8.61.1", + "@vitest/coverage-v8": "^3.0.4", "drizzle-kit": "^0.30.1", "eslint": "^9.17.0", "pino-pretty": "^13.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "^8.61.1", - "vitest": "^3.0.4", - "@vitest/coverage-v8": "^3.0.4" + "vitest": "^3.0.4" } } diff --git a/src/audit/index.ts b/src/audit/index.ts index 81a586f..2b7241c 100644 --- a/src/audit/index.ts +++ b/src/audit/index.ts @@ -1,6 +1,7 @@ import { logger } from "../utils/logger.js"; import { db } from "../config/database.js"; import { auditLogs } from "../database/schema.js"; +import { getRequestId } from "../utils/request-context.js"; type AuditEvent = | "quiz.submitted" @@ -24,13 +25,15 @@ interface AuditFields { queued?: boolean; ip?: string; userAgent?: string; + requestId?: string; } export async function auditLog(event: AuditEvent, fields: AuditFields): Promise { - logger.info({ audit: true, event, ...fields }, `audit: ${event}`); + const auditFields = { requestId: getRequestId(), ...fields }; + logger.info({ audit: true, event, ...auditFields }, `audit: ${event}`); for (let attempt = 0; attempt < 3; attempt++) { try { - await db.insert(auditLogs).values({ event, fields }); + await db.insert(auditLogs).values({ event, fields: auditFields }); return; } catch (err) { if (attempt < 2) { @@ -39,7 +42,7 @@ export async function auditLog(event: AuditEvent, fields: AuditFields): Promise< } logger.error({ err }, "Failed to persist audit log after 3 attempts"); process.stdout.write( - JSON.stringify({ audit: true, event, ...fields, persistError: String(err) }) + "\n", + JSON.stringify({ audit: true, event, ...auditFields, persistError: String(err) }) + "\n", ); } } diff --git a/src/database/migrations/0010_users_stellar_address_index.sql b/src/database/migrations/0010_users_stellar_address_index.sql new file mode 100644 index 0000000..003c816 --- /dev/null +++ b/src/database/migrations/0010_users_stellar_address_index.sql @@ -0,0 +1,2 @@ +CREATE INDEX IF NOT EXISTS idx_users_stellar_address + ON users (stellar_address); diff --git a/src/database/schema.ts b/src/database/schema.ts index 2a0d5d8..a33eb65 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -38,7 +38,8 @@ export const users = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }) .notNull() .defaultNow(), - } + }, + (table) => [index("idx_users_stellar_address").on(table.stellarAddress)] ); // ─── Courses ──────────────────────────────────────────────────────────────── diff --git a/src/modules/courses/course.routes.ts b/src/modules/courses/course.routes.ts index 04f4dcb..5b80636 100644 --- a/src/modules/courses/course.routes.ts +++ b/src/modules/courses/course.routes.ts @@ -12,6 +12,15 @@ export async function courseRoutes(app: FastifyInstance): Promise { schema: { description: "List available courses", tags: ["courses"], + querystring: { + type: "object", + properties: { + difficulty: { type: "string", enum: ["beginner", "intermediate", "advanced"] }, + search: { type: "string" }, + page: { type: "integer", minimum: 1, default: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + }, + }, } as FastifySchema, }, (request, reply) => courseController.list(request, reply) @@ -24,6 +33,7 @@ export async function courseRoutes(app: FastifyInstance): Promise { schema: { description: "Get course details by ID", tags: ["courses"], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, } as FastifySchema, }, (request, reply) => courseController.getById(request, reply) @@ -36,6 +46,8 @@ export async function courseRoutes(app: FastifyInstance): Promise { schema: { description: "Enroll in a course", tags: ["courses"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, } as FastifySchema, }, (request, reply) => courseController.enroll(request, reply) diff --git a/src/modules/courses/course.service.ts b/src/modules/courses/course.service.ts index 5ac1334..e60d75e 100644 --- a/src/modules/courses/course.service.ts +++ b/src/modules/courses/course.service.ts @@ -1,4 +1,4 @@ -import { eq, and, count, desc, inArray } from "drizzle-orm"; +import { eq, and, count, desc, inArray, ilike, or } from "drizzle-orm"; import { db } from "../../config/database.js"; import { courses, enrollments, quizzes } from "../../database/schema.js"; import { NotFoundError, ConflictError } from "../../utils/errors.js"; @@ -50,10 +50,12 @@ export class CourseService { query: ListCoursesQuery, ): Promise<{ courses: CourseSummary[]; total: number }> { const namespace = "courses"; + const search = query.search?.trim() || undefined; const cacheKeyString = cacheKey( namespace, "list", query.difficulty ?? "all", + search ? encodeURIComponent(search.toLowerCase()) : "all", query.page, query.limit, ); @@ -68,6 +70,14 @@ export class CourseService { if (query.difficulty) { conditions.push(eq(courses.difficulty, query.difficulty)); } + if (search) { + conditions.push( + or( + ilike(courses.title, `%${search}%`), + ilike(courses.description, `%${search}%`), + )!, + ); + } const where = and(...conditions); const offset = (query.page - 1) * query.limit; diff --git a/src/modules/courses/course.types.ts b/src/modules/courses/course.types.ts index fcc6a83..55757b1 100644 --- a/src/modules/courses/course.types.ts +++ b/src/modules/courses/course.types.ts @@ -4,6 +4,7 @@ import { z } from "zod"; export const listCoursesSchema = z.object({ difficulty: z.enum(["beginner", "intermediate", "advanced"]).optional(), + search: z.string().optional(), page: z.coerce.number().int().min(1).default(1), limit: z.coerce.number().int().min(1).max(50).default(20), }); diff --git a/src/modules/credentials/credential.routes.ts b/src/modules/credentials/credential.routes.ts index a4654ec..3dae5c1 100644 --- a/src/modules/credentials/credential.routes.ts +++ b/src/modules/credentials/credential.routes.ts @@ -14,6 +14,15 @@ export async function credentialRoutes(app: FastifyInstance): Promise { schema: { description: "Mint a course completion credential (NFT)", tags: ["credentials"], + security: [{ bearerAuth: [] }], + body: { + type: "object", required: ["courseId", "submissionId", "idempotencyKey"], + properties: { + courseId: { type: "string", format: "uuid" }, + submissionId: { type: "string", format: "uuid" }, + idempotencyKey: { type: "string", minLength: 16, maxLength: 64 }, + }, + }, } as FastifySchema, }, (request, reply) => credentialController.mint(request, reply) @@ -25,6 +34,7 @@ export async function credentialRoutes(app: FastifyInstance): Promise { schema: { description: "List user credentials", tags: ["credentials"], + security: [{ bearerAuth: [] }], } as FastifySchema, }, (request, reply) => credentialController.list(request, reply) diff --git a/src/modules/quizzes/ai-client.ts b/src/modules/quizzes/ai-client.ts index ad74b6d..4c20dc4 100644 --- a/src/modules/quizzes/ai-client.ts +++ b/src/modules/quizzes/ai-client.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { config } from "../../config/index.js"; import { logger } from "../../utils/logger.js"; import { createTransientRetryPolicy, createCircuitBreaker } from "../../utils/resilience.js"; +import { context, propagation } from "@opentelemetry/api"; +import { getRequestId } from "../../utils/request-context.js"; const aiQuizQuestionSchema = z.object({ prompt: z.string(), @@ -36,9 +38,14 @@ async function requestQuiz( const timeout = setTimeout(() => controller.abort(), config.AI_TIMEOUT_MS); try { + const headers: Record = { "Content-Type": "application/json" }; + const requestId = getRequestId(); + if (requestId) headers["X-Request-ID"] = requestId; + propagation.inject(context.active(), headers); + const response = await fetch(`${config.AI_SERVICE_URL}/generate-quiz`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ user_id: params.userId, course_id: params.courseId, diff --git a/src/modules/quizzes/quiz.routes.ts b/src/modules/quizzes/quiz.routes.ts index e78be4f..7f88582 100644 --- a/src/modules/quizzes/quiz.routes.ts +++ b/src/modules/quizzes/quiz.routes.ts @@ -14,6 +14,17 @@ export async function quizRoutes(app: FastifyInstance): Promise { schema: { description: "Generate a quiz for a course module", tags: ["quizzes"], + security: [{ bearerAuth: [] }], + body: { + type: "object", + required: ["courseId", "moduleId"], + properties: { + courseId: { type: "string", format: "uuid" }, + moduleId: { type: "string", minLength: 1 }, + difficulty: { type: "string", enum: ["beginner", "intermediate", "advanced"] }, + numQuestions: { type: "integer", minimum: 1, maximum: 20 }, + }, + }, } as FastifySchema, }, (request, reply) => quizController.generate(request, reply) @@ -28,6 +39,24 @@ export async function quizRoutes(app: FastifyInstance): Promise { schema: { description: "Submit quiz answers", tags: ["quizzes"], + security: [{ bearerAuth: [] }], + params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } }, + body: { + type: "object", + required: ["answers"], + properties: { + answers: { + type: "array", minItems: 1, maxItems: 50, + items: { + type: "object", required: ["questionId", "selectedIndex"], + properties: { + questionId: { type: "string", minLength: 1, maxLength: 100 }, + selectedIndex: { type: "integer", minimum: 0, maximum: 20 }, + }, + }, + }, + }, + }, } as FastifySchema, }, (request, reply) => quizController.submit(request, reply) diff --git a/src/modules/rewards/reward.routes.ts b/src/modules/rewards/reward.routes.ts index 10cdb2c..1027fbb 100644 --- a/src/modules/rewards/reward.routes.ts +++ b/src/modules/rewards/reward.routes.ts @@ -16,6 +16,14 @@ export async function rewardRoutes(app: FastifyInstance): Promise { schema: { description: "Claim a reward for a passed quiz", tags: ["rewards"], + security: [{ bearerAuth: [] }], + body: { + type: "object", required: ["submissionId", "idempotencyKey"], + properties: { + submissionId: { type: "string", format: "uuid" }, + idempotencyKey: { type: "string", minLength: 16, maxLength: 64 }, + }, + }, } as FastifySchema, }, (request, reply) => rewardController.claim(request, reply) @@ -28,6 +36,14 @@ export async function rewardRoutes(app: FastifyInstance): Promise { schema: { description: "Get reward claim history", tags: ["rewards"], + security: [{ bearerAuth: [] }], + querystring: { + type: "object", + properties: { + page: { type: "integer", minimum: 1, default: 1 }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + }, + }, } as FastifySchema, }, (request, reply) => rewardController.history(request, reply) diff --git a/src/modules/users/user.routes.ts b/src/modules/users/user.routes.ts index a12fc6b..dbd1b67 100644 --- a/src/modules/users/user.routes.ts +++ b/src/modules/users/user.routes.ts @@ -13,6 +13,7 @@ export async function userRoutes(app: FastifyInstance): Promise { schema: { description: "Get authenticated user profile", tags: ["users"], + security: [{ bearerAuth: [] }], } as FastifySchema, }, (request, reply) => userController.getMe(request, reply) @@ -25,6 +26,17 @@ export async function userRoutes(app: FastifyInstance): Promise { schema: { description: "Update authenticated user profile", tags: ["users"], + security: [{ bearerAuth: [] }], + body: { + type: "object", + properties: { + displayName: { type: "string", minLength: 1, maxLength: 100 }, + background: { type: "string", maxLength: 1000 }, + learningGoal: { type: "string", maxLength: 500 }, + pace: { type: "string", enum: ["slow", "medium", "fast"] }, + language: { type: "string", maxLength: 10 }, + }, + }, } as FastifySchema, }, (request, reply) => userController.updateMe(request, reply) @@ -36,6 +48,7 @@ export async function userRoutes(app: FastifyInstance): Promise { schema: { description: "Get learning progress stats", tags: ["users"], + security: [{ bearerAuth: [] }], } as FastifySchema, }, (request, reply) => userController.getProgress(request, reply) diff --git a/src/server.ts b/src/server.ts index d62b20a..57aec76 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,6 +5,8 @@ import cors from "@fastify/cors"; import helmet from "@fastify/helmet"; import jwt from "@fastify/jwt"; import rateLimit from "@fastify/rate-limit"; +import swagger from "@fastify/swagger"; +import swaggerUi from "@fastify/swagger-ui"; import { sql } from "drizzle-orm"; import { config } from "./config/index.js"; import { logger } from "./utils/logger.js"; @@ -32,6 +34,7 @@ import { } from "./jobs/reconcile-pending-rewards.js"; import { processRewardClaim } from "./modules/rewards/reward.service.js"; import { warmCourseCache } from "./cache/warmer.js"; +import { runWithRequestContext } from "./utils/request-context.js"; // Versioned route modules import { registerVersionedRoutes } from "./routes/versioning.js"; @@ -75,6 +78,35 @@ async function buildApp() { genReqId: () => crypto.randomUUID(), }); + app.addHook("onRequest", (request, _reply, done) => { + runWithRequestContext(request.id, done); + }); + + await app.register(swagger, { + openapi: { + info: { + title: "ChainLearn API", + description: "API for the ChainLearn Stellar-based learning platform", + version: "1.0.0", + }, + components: { + securitySchemes: { + bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" }, + }, + }, + tags: [ + { name: "auth", description: "SEP-10 authentication" }, + { name: "users", description: "User profile and progress" }, + { name: "courses", description: "Course discovery and enrollment" }, + { name: "quizzes", description: "Quiz generation and submission" }, + { name: "rewards", description: "Learning rewards" }, + { name: "credentials", description: "Course credentials" }, + ], + }, + }); + + await app.register(swaggerUi, { routePrefix: "/docs" }); + // ─── Plugins ──────────────────────────────────────────────────────────── // #220: OWASP security headers via @fastify/helmet. @@ -84,7 +116,9 @@ async function buildApp() { contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], - scriptSrc: ["'none'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + imgSrc: ["'self'", "data:"], objectSrc: ["'none'"], frameAncestors: ["'none'"], }, diff --git a/src/stellar/client.ts b/src/stellar/client.ts index b407e2a..6ff7611 100644 --- a/src/stellar/client.ts +++ b/src/stellar/client.ts @@ -11,6 +11,7 @@ import { circuitBreakerExecute, withTimeout, } from "./resilience.js"; +import { getRequestId } from "../utils/request-context.js"; const READ_TIMEOUT_MS = 10_000; const WRITE_TIMEOUT_MS = 30_000; @@ -34,6 +35,7 @@ export class StellarClient { async getAccount( publicKey: string, ): Promise { + logger.debug({ requestId: getRequestId(), publicKey }, "Loading Stellar account"); try { return await circuitBreakerExecute( () => @@ -52,6 +54,8 @@ export class StellarClient { async submitTransaction( txEnvelope: StellarSdk.Transaction | StellarSdk.FeeBumpTransaction, ): Promise { + const requestId = getRequestId(); + logger.debug({ requestId }, "Submitting Stellar transaction"); try { const result = await circuitBreakerExecute(() => stellarRetry.execute(() => @@ -61,7 +65,7 @@ export class StellarClient { ), ), ); - logger.info({ hash: result.hash }, "Transaction submitted successfully"); + logger.info({ requestId, hash: result.hash }, "Transaction submitted successfully"); return result; } catch (err: any) { const extras = err.response?.data?.extras; @@ -86,6 +90,7 @@ export class StellarClient { async callContract( tx: StellarSdk.Transaction, ): Promise { + logger.debug({ requestId: getRequestId() }, "Simulating Stellar contract call"); try { return await circuitBreakerExecute(() => stellarRetry.execute(() => @@ -105,6 +110,7 @@ export class StellarClient { * silently misled into treating an unreachable network as a missing account. */ async accountExists(publicKey: string): Promise { + logger.debug({ requestId: getRequestId(), publicKey }, "Checking Stellar account"); try { await circuitBreakerExecute( () => @@ -137,6 +143,7 @@ export class StellarClient { * Used by the pending-reward reconciliation job (#207). */ async getTransaction(txHash: string): Promise<{ status: "SUCCESS" | "FAILED" | "NOT_FOUND" }> { + logger.debug({ requestId: getRequestId(), txHash }, "Fetching Stellar transaction"); try { const result = await withTimeout( this.soroban.getTransaction(txHash), diff --git a/src/utils/request-context.ts b/src/utils/request-context.ts new file mode 100644 index 0000000..d3708af --- /dev/null +++ b/src/utils/request-context.ts @@ -0,0 +1,14 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +const requestContext = new AsyncLocalStorage<{ requestId: string }>(); + +export function runWithRequestContext( + requestId: string, + callback: () => void, +): void { + requestContext.run({ requestId }, callback); +} + +export function getRequestId(): string | undefined { + return requestContext.getStore()?.requestId; +} diff --git a/tests/e2e/docs.test.ts b/tests/e2e/docs.test.ts new file mode 100644 index 0000000..c62dc5c --- /dev/null +++ b/tests/e2e/docs.test.ts @@ -0,0 +1,33 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../../src/server.js"; + +describe("OpenAPI documentation", () => { + let app: FastifyInstance; + + beforeAll(async () => { + app = await buildApp(); + await app.ready(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("serves Swagger UI and the generated OpenAPI specification", async () => { + const [ui, spec] = await Promise.all([ + app.inject({ method: "GET", url: "/docs/" }), + app.inject({ method: "GET", url: "/docs/json" }), + ]); + + expect(ui.statusCode).toBe(200); + expect(spec.statusCode).toBe(200); + const document = spec.json(); + expect(document.openapi).toBe("3.0.3"); + expect(Object.keys(document.paths)).toContain("/api/v1/courses/"); + expect(document.paths["/api/v1/courses/"].get.parameters).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "search" })]), + ); + expect(document.components.securitySchemes.bearerAuth).toBeDefined(); + }); +}); diff --git a/tests/unit/quizzes/ai-client.test.ts b/tests/unit/quizzes/ai-client.test.ts index 9f89216..cf7bff8 100644 --- a/tests/unit/quizzes/ai-client.test.ts +++ b/tests/unit/quizzes/ai-client.test.ts @@ -50,6 +50,29 @@ describe("generateQuizFromAI", () => { expect(result).toEqual(validBody.questions); }); + it("propagates the current request ID to the AI service", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { + quiz_id: "quiz-1", + questions: [{ prompt: "Q1", options: ["a", "b"], correct_index: 0 }], + })); + vi.stubGlobal("fetch", fetchMock); + + const { generateQuizFromAI } = await loadClient(); + const { runWithRequestContext } = await import("../../../src/utils/request-context.js"); + await new Promise((resolve, reject) => { + runWithRequestContext("request-123", () => { + generateQuizFromAI(baseParams).then(() => resolve(), reject); + }); + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://ai.test/generate-quiz", + expect.objectContaining({ + headers: expect.objectContaining({ "X-Request-ID": "request-123" }), + }), + ); + }); + it("throws when the AI response shape is malformed instead of crashing (#142)", async () => { vi.stubGlobal( "fetch", diff --git a/tests/unit/schemas/input-validation.test.ts b/tests/unit/schemas/input-validation.test.ts index c289949..898f298 100644 --- a/tests/unit/schemas/input-validation.test.ts +++ b/tests/unit/schemas/input-validation.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect } from "vitest"; import { updateProfileSchema } from "../../../src/modules/users/user.types.js"; import { submitQuizSchema } from "../../../src/modules/quizzes/quiz.types.js"; +import { listCoursesSchema } from "../../../src/modules/courses/course.types.js"; + +describe("listCoursesSchema", () => { + it("accepts an optional search term", () => { + expect(listCoursesSchema.parse({ search: "stellar" }).search).toBe("stellar"); + }); + + it("accepts an empty search term so the service can ignore it", () => { + expect(listCoursesSchema.safeParse({ search: "" }).success).toBe(true); + }); +}); describe("updateProfileSchema", () => { it("sanitizes HTML out of free-text fields", () => {