diff --git a/gradesa/src/backend/auth/hash.js b/gradesa/src/backend/auth/hash.js index 6133849..fb632d9 100644 --- a/gradesa/src/backend/auth/hash.js +++ b/gradesa/src/backend/auth/hash.js @@ -1,5 +1,20 @@ const crypto = require("crypto"); +/** + * Password hashing and verification utilities using Node's `crypto.scrypt`. + * + * These helpers produce a per-user random salt and a hex-encoded hash. The + * project stores `salt` and `password_hash` in the `users` table and verifies + * logins by recomputing the scrypt hash with the stored salt. + */ + +/** + * Hashes a plaintext password with scrypt and a salt. + * + * @param {string} password - Plaintext password to hash. + * @param {string} [salt] - Optional salt; generates a random 16-byte hex salt when omitted. + * @returns {Promise<{ salt: string, hashedPassword: string }>} Resolves with the salt and hex-encoded hash. + */ export async function hashPassword( password, salt = crypto.randomBytes(16).toString("hex") @@ -13,6 +28,14 @@ export async function hashPassword( }); } +/** + * Verifies a plaintext password against a stored salt and hash. + * + * @param {string} password - Plaintext password provided by the user. + * @param {string} salt - The user's stored salt. + * @param {string} hashedPasswordFromDB - The user's stored hex-encoded hash. + * @returns {Promise} Resolves `true` when the recomputed hash matches, else `false`. + */ export async function verifyPassword(password, salt, hashedPasswordFromDB) { return new Promise((resolve, reject) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => { diff --git a/gradesa/src/backend/auth/session.js b/gradesa/src/backend/auth/session.js index 99ceaf2..817af64 100644 --- a/gradesa/src/backend/auth/session.js +++ b/gradesa/src/backend/auth/session.js @@ -4,9 +4,23 @@ import { cookies } from "next/headers"; import { getConfig, isTest } from "../../backend/config"; import { AUTH_COOKIE_NAME } from "@/shared/const"; +/** + * Session management utilities using signed JWTs stored in HTTP-only cookies. + * + * - Secret and TTL come from `getConfig()` (`sessionSecret`, `sessionTTL`). + * - JWTs are signed with HS256 and include `{ user, expiresAt }`. + * - In tests (`isTest`), cookie operations are skipped; `checkSession` returns + * `request.testUser` when provided. + */ const { sessionSecret, sessionTTL } = getConfig(); const encodedKey = new TextEncoder().encode(sessionSecret); +/** + * Signs an arbitrary payload into a JWT using HS256 and configured TTL. + * + * @param {Record} payload - Object to embed in the token. + * @returns {Promise} A compact JWT string. + */ export async function signPayload(payload) { return new SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) @@ -15,6 +29,12 @@ export async function signPayload(payload) { .sign(encodedKey); } +/** + * Verifies a JWT and returns its payload when valid. + * + * @param {string} session - Compact JWT string from the cookie. + * @returns {Promise | undefined>} Payload if valid; otherwise `undefined`. + */ export async function verifySession(session) { try { const { payload } = await jwtVerify(session, encodedKey, { @@ -26,6 +46,16 @@ export async function verifySession(session) { } } +/** + * Creates a new session cookie for a user. + * + * Stores a signed JWT in `AUTH_COOKIE_NAME` with `httpOnly`, `sameSite:lax`, + * and environment-appropriate `secure`. Skips cookie setting in tests. + * + * @param {{ id:number, username:string, email:string, is_admin:boolean }} user - + * Minimal user info to embed in the session. + * @returns {Promise} Resolves when the cookie is set (or skipped in tests). + */ export async function createSession(user) { const expiresAt = new Date(Date.now() + sessionTTL); const payload = { @@ -55,6 +85,14 @@ export async function createSession(user) { }); } +/** + * Reads the current session cookie and returns the embedded user. + * + * In tests, returns `request.testUser` to aid mocking. + * + * @param {Request & { testUser?: any }} [request] - Optional request object providing `testUser` in tests. + * @returns {Promise<{ id:number, username:string, email:string, is_admin:boolean } | null>} The user or `null`. + */ export async function checkSession(request) { if (isTest) { return request.testUser; @@ -70,6 +108,10 @@ export async function checkSession(request) { return null; } +/** + * Deletes the current session cookie. + * @returns {Promise} Resolves when the cookie is removed. + */ export async function deleteSession() { const cookieStore = await cookies(); cookieStore.delete(AUTH_COOKIE_NAME); diff --git a/gradesa/src/backend/config.js b/gradesa/src/backend/config.js index 3d6faaa..f41ae13 100644 --- a/gradesa/src/backend/config.js +++ b/gradesa/src/backend/config.js @@ -1,15 +1,40 @@ import { config } from "dotenv"; +/** + * Environment and configuration helpers. + * + * This module centralizes reading environment variables from `.env` and + * `.env.`, and exposes a structured `getConfig()` object for + * application settings, including PostgreSQL connection parameters. + */ + +/** + * Current runtime environment (e.g., `development`, `test`, `production`). + * @type {string | undefined} + */ export const environment = process.env.NODE_ENV; +/** Whether running in test environment. */ export const isTest = environment === "test"; +/** Whether running in development environment. */ export const isDevelopment = environment === "development"; +/** Whether running in production environment. */ export const isProduction = environment === "production"; // Loads .env and .env.NODE_ENV (e.g. .env.development) // Shared environment variables are in .env // Environment specific variables are in .env.NODE_ENV // NextJS defaults to 'development' when running npm run dev +/** + * Loads environment variable files. + * + * - Always loads `.env` + * - Then loads `.env.` if `environment` is one of + * `test`, `development`, or `production`. + * + * Note: This function is synchronous despite being declared `async` in + * some usages; dotenv's `config` is synchronous. + */ export const initConfig = async () => { switch (environment) { case "test": @@ -19,7 +44,8 @@ export const initConfig = async () => { config({ path: `${process.cwd()}/.env.${environment}`, override: true }); break; default: - throw new Error("Unsupported environment: " + env); + // Provide a clearer error without referencing undefined variables. + throw new Error("Unsupported environment: " + String(environment)); } }; @@ -27,8 +53,28 @@ export const initConfig = async () => { // It's used to read environment variables from .env and .env.NODE_ENV // So instead of using process.env.DB_HOST, you can use getConfig().db.host // This is useful for keeping the code clean and gives you clear errors if you miss a variable. +/** @type {ReturnType | undefined} */ let cachedConfig; +/** + * Returns the cached application configuration, creating it on first access. + * Ensures environment files are loaded before reading variables. + * + * @returns {{ + * url: string, + * apiUrl: string, + * sessionSecret: string, + * sessionTTL: number, + * db: { + * host: string, + * port: number, + * user: string, + * password: string, + * database: string, + * ssl: boolean, + * } + * }} + */ export const getConfig = () => { if (cachedConfig) { return cachedConfig; @@ -38,6 +84,11 @@ export const getConfig = () => { return cachedConfig; }; +/** + * Reads a raw value from `process.env`. + * @param {string} key - Environment variable name. + * @returns {string | undefined} The value or `undefined` if not set. + */ function getFromEnv(key) { const val = process.env[key]; if (val === undefined) { @@ -46,9 +97,22 @@ function getFromEnv(key) { return val; } +/** + * Builds the structured configuration object from environment variables. + * Validates presence and basic type parsing, accumulating missing keys + * and throwing an error if any are invalid. + * + * @returns {ReturnType} + * @throws {Error} If required environment variables are missing or invalid. + */ function create() { let errors = []; + /** + * Reads a non-empty string env var; records missing/empty keys. + * @param {string} key + * @returns {string} + */ function readString(key) { const val = getFromEnv(key); if (val === undefined || val.trim() === "") { @@ -57,6 +121,11 @@ function create() { return val ?? ""; } + /** + * Reads a required integer env var; records missing keys. + * @param {string} key + * @returns {number} + */ function readInt(key) { const val = getFromEnv(key); if (val === undefined) { @@ -66,11 +135,18 @@ function create() { return parseInt(val); } + /** + * Reads an optional integer env var with a default fallback. + * @param {string} key + * @param {number} defaultValue + * @returns {number} + */ function readIntOptional(key, defaultValue) { const value = getFromEnv(key); return value === undefined ? defaultValue : parseInt(value); } + /** @type {ReturnType} */ const config = { url: "http://localhost:3000", apiUrl: "http://localhost:3000/api", diff --git a/gradesa/src/backend/db.js b/gradesa/src/backend/db.js index f2ee45b..9ffc62e 100644 --- a/gradesa/src/backend/db.js +++ b/gradesa/src/backend/db.js @@ -1,13 +1,37 @@ import { Pool } from "pg"; import { getConfig } from "./config"; import { isTest } from "./config"; +import { logger } from "./logging"; + +/** + * Database utilities for managing a shared PostgreSQL connection pool. + * + * This module centralizes pool creation, simple queries, and transactional + * execution. In test environments (`isTest`), helpers like `set` and `reset` + * allow substituting and tearing down the pool safely. + */ + +/** + * Builds a PostgreSQL connection URL from a configuration object. + * + * @param {{ user: string, password: string, host: string, port: number|string, database: string }} config - + * Basic connection fields used to construct the URL. + * @returns {string} The postgres connection URL (e.g., `postgres://user:pass@host:port/db`). + */ export function dbURL(config) { return `postgres://${config.user}:${config.password}@${config.host}:${config.port}/${config.database}`; } -import { logger } from "./logging"; +/** @type {import('pg').Pool | undefined} */ let _pool; +/** + * Verifies a connection can be obtained from the given pool. + * Logs a successful connection (non-test only) and always releases the client. + * + * @param {import('pg').Pool} pool - A previously constructed Pool instance. + * @returns {Promise} Resolves to the provided pool. + */ async function connect(pool) { let client; try { @@ -33,6 +57,12 @@ async function connect(pool) { return pool; } +/** + * Lazily creates and returns the shared connection pool. + * + * Configuration is loaded via `getConfig().db` on first access. + * @returns {Promise} Resolves to the shared Pool. + */ async function get() { if (!_pool) { const config = getConfig(); @@ -42,21 +72,27 @@ async function get() { } /** - * Executes a database query using the connection pool. + * Executes a database query using the shared pool. * - * @param {string} text - The SQL query text to be executed. - * @param {Array} [params] - An optional array of parameters to be used in the query. - * @returns {Promise} - A promise that resolves to the result of the query. + * @param {string} text - SQL query text to be executed. + * @param {Array} [params] - Optional positional parameters for the query. + * @returns {Promise} Resolves to the query result. */ async function pool(text, params) { return (await get()).query(text, params); } /** - * Executes a database transaction using the connection pool. + * Executes a database transaction using the shared pool. * - * @param {Function} fn - A function that takes a client as an argument and returns a promise. - * @returns {Promise} - A promise that resolves to the result of the transaction. + * The provided function receives a `PoolClient` which must be used + * for all queries in the transaction scope. + * + * @template T + * @param {(client: import('pg').PoolClient) => Promise} fn - + * Function performing transactional work with the provided client. + * @returns {Promise} Resolves to the function's result after COMMIT. + * @throws Will rethrow the original error after ROLLBACK on failure. */ async function transaction(fn) { const db = await get(); @@ -74,17 +110,28 @@ async function transaction(fn) { } } +/** + * Test-only: Replace the shared pool with a new one created from `config`. + * Ensures a connection can be obtained before swapping the pool. + * + * @param {import('pg').PoolConfig} config - Configuration for the new pool. + * @returns {Promise} Resolves when the pool has been replaced. + * @throws If called outside test environment. + */ async function set(config) { if (!isTest) { - throw new Error( - "Cannot set database in non-test environment, was " + environment - ); + throw new Error("Cannot set database in non-test environment"); } const newPool = new Pool(config); await connect(newPool); _pool = newPool; } +/** + * Gracefully ends the current shared pool, if present. + * Safe to call multiple times. + * @returns {Promise} Resolves when the pool is shut down. + */ async function destroy() { if (_pool !== undefined) { if (!_pool.ended && !_pool.ending) { @@ -95,21 +142,41 @@ async function destroy() { _pool = undefined; } +/** + * Ensures the shared pool can provide a client. + * Useful as a readiness check; returns the pool itself. + * + * @returns {Promise} Resolves to the shared pool. + */ async function getClient() { return connect(await get()); } +/** + * Test-only: Tear down the shared pool and clear internal state. + * + * @returns {Promise} Resolves when the pool has been reset. + * @throws If called outside test environment. + */ async function reset() { - if (!isTest) - throw new Error( - "Cannot reset database in non-test environment, was " + environment - ); + if (!isTest) throw new Error("Cannot reset database in non-test environment"); if (_pool !== undefined) { await destroy(); } _pool = undefined; } +/** + * Facade for database interactions. + * + * - `get`: Lazily get the shared pool + * - `pool`: Run a single query via the shared pool + * - `transaction`: Run a function within a transaction + * - `set` (test): Replace the pool + * - `destroy`: End the pool + * - `getClient`: Ensure pool connectivity (returns the pool) + * - `reset` (test): Destroy and clear the pool + */ export const DB = { get, pool, diff --git a/gradesa/src/backend/html-services.js b/gradesa/src/backend/html-services.js index 35eb7f9..30bcd80 100644 --- a/gradesa/src/backend/html-services.js +++ b/gradesa/src/backend/html-services.js @@ -1,5 +1,21 @@ import { DB } from "@/backend/db"; +/** + * Services for reading and writing HTML page content stored in the database. + * + * Pages are grouped by `page_group` (e.g., type/category) and identified by a `slug`. + * The schema expects columns: `title`, `content`, `page_order`, `slug`, `page_group`. + */ + +/** + * Fetches a single HTML page's data by group and slug. + * + * @param {string} type - The `page_group` to filter by. + * @param {string} slug - The unique slug within the group. + * @returns {Promise<{title:string, content:string, page_order:number, slug:string, page_group:string}>} + * Resolves with the page row when found. + * @throws {Error} If the page does not exist. + */ export async function getPageData(type, slug) { const result = await DB.pool( "SELECT title, content, page_order, slug, page_group FROM html_pages WHERE page_group = $1 AND slug = $2", @@ -12,6 +28,24 @@ export async function getPageData(type, slug) { return result.rows[0]; } +/** + * Updates an HTML page's core fields (`title`, `slug`, `content`). + * + * Matching is done by the provided `type` (maps to `page_group`) and the + * current `slug` value. If the row is found, its fields are updated and the + * operation returns `true`. If no row matches (e.g., wrong `type` or `slug`), + * no changes are made and the function returns `false`. + * + * Important: + * - This function can change the `slug`. Subsequent lookups should use the + * new slug. + * - It does not modify `page_order` or `page_group`. + * + * @param {string} type - The `page_group` of the page to update. + * @param {string} slug - The current slug of the page to update. + * @param {{title:string, slug:string, content:string}} newData - New values to set. + * @returns {Promise} Resolves `true` when exactly one row was updated; otherwise `false`. + */ export async function setPageData(type, slug, newData) { const result = await DB.pool( "UPDATE html_pages SET title = $1, slug = $2, content = $3 WHERE page_group = $4 AND slug = $5", @@ -20,6 +54,12 @@ export async function setPageData(type, slug, newData) { return result.rowCount == 1; } +/** + * Lists pages (title and slug) within a group, ordered by `page_order` ascending. + * + * @param {string} type - The `page_group` to list. + * @returns {Promise>} Resolves with page summaries. + */ export async function getPageList(type) { const result = await DB.pool( "SELECT title, slug FROM html_pages WHERE page_group = $1 ORDER BY page_order ASC", diff --git a/gradesa/src/backend/middleware/withAuth.js b/gradesa/src/backend/middleware/withAuth.js index ca352b2..f96edd4 100644 --- a/gradesa/src/backend/middleware/withAuth.js +++ b/gradesa/src/backend/middleware/withAuth.js @@ -1,6 +1,24 @@ import { NextResponse } from "next/server"; import { checkSession } from "@/backend/auth/session"; +/** + * Wraps a Next.js Route Handler with authentication/authorization checks. + * + * Usage examples appear in `src/backend/middleware/README.md` and various + * API routes (e.g., `src/app/api/.../route.js`). + * + * Behavior: + * - Reads the current user via `checkSession(request)`. + * - If `requireAuth` is true and no user, responds `401 Unauthorized`. + * - If `requireAdmin` is true and user is not admin, responds `401 Unauthorized`. + * - Otherwise attaches `request.user` and invokes `callback(request, ...args)`. + * + * @param {(request: Request, ...args: any[]) => Promise|Response} callback - + * The original handler to execute when checks pass. + * @param {{ requireAuth?: boolean, requireAdmin?: boolean }} [options] - + * Control whether authentication and/or admin rights are required. + * @returns {(request: Request, ...args: any[]) => Promise} A wrapped handler. + */ export function withAuth(callback, options = {}) { const { requireAuth = true, requireAdmin = false } = options; return async (request, ...args) => { diff --git a/gradesa/src/backend/middleware/withInputValidation.js b/gradesa/src/backend/middleware/withInputValidation.js index 59eb303..f08185c 100644 --- a/gradesa/src/backend/middleware/withInputValidation.js +++ b/gradesa/src/backend/middleware/withInputValidation.js @@ -2,6 +2,24 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { logger } from "../logging"; +/** + * Wraps a Next.js API route handler with Zod-based input validation. + * + * The wrapper reads the JSON body, validates it against the provided `schema`, + * and forwards the request to `callback` with a new `NextRequest` whose body is + * replaced by the validated payload. When validation fails, it returns a + * `422 Unprocessable Entity` response including a human-readable error and the + * Zod error details. Unexpected errors return `500`. + * + * Common usage: + * - Compose with `withAuth` to enforce auth, then validate input. + * - In the callback, call `await req.json()` to read the validated payload. + * + * @param {z.ZodTypeAny} schema - Zod schema describing expected request body. + * @param {(req: NextRequest, res?: Response) => Promise|Response} callback - + * Handler invoked with a request whose body is the validated payload. + * @returns {(req: NextRequest, res?: Response) => Promise} Wrapped handler. + */ export const withInputValidation = (schema, callback) => { return async (req, res) => { const request = await req.json(); @@ -48,6 +66,14 @@ export const withInputValidation = (schema, callback) => { }; }; +/** + * Produces human-readable and debug-friendly strings from a Zod error. + * + * @param {unknown} error - The thrown error, expected to be `z.ZodError`. + * @returns {{ humanReadableError: string, debugError: string[] } | string} + * For Zod errors, returns a summary string and a list of field-specific messages; + * otherwise returns `error.toString()`. + */ const humanReadableZodError = (error) => { if (!(error instanceof z.ZodError)) { return error.toString();