Wrote, generated and adjusted documentation for backend

This commit is contained in:
LosEvons
2025-11-30 21:27:26 +00:00
parent 1ab71e887b
commit ad487a6a97
7 changed files with 308 additions and 16 deletions
+23
View File
@@ -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<boolean>} 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) => {
+42
View File
@@ -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<string, any>} payload - Object to embed in the token.
* @returns {Promise<string>} 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<Record<string, any> | 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<void>} 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<void>} Resolves when the cookie is removed.
*/
export async function deleteSession() {
const cookieStore = await cookies();
cookieStore.delete(AUTH_COOKIE_NAME);
+77 -1
View File
@@ -1,15 +1,40 @@
import { config } from "dotenv";
/**
* Environment and configuration helpers.
*
* This module centralizes reading environment variables from `.env` and
* `.env.<NODE_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.<environment>` 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<typeof create> | 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<typeof getConfig>}
* @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<typeof getConfig>} */
const config = {
url: "http://localhost:3000",
apiUrl: "http://localhost:3000/api",
+82 -15
View File
@@ -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<import('pg').Pool>} 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<import('pg').Pool>} 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<Object>} - A promise that resolves to the result of the query.
* @param {string} text - SQL query text to be executed.
* @param {Array<any>} [params] - Optional positional parameters for the query.
* @returns {Promise<import('pg').QueryResult>} 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<Object>} - 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<T>} fn -
* Function performing transactional work with the provided client.
* @returns {Promise<T>} 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<void>} 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<void>} 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<import('pg').Pool>} 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<void>} 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,
+40
View File
@@ -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<boolean>} 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<Array<{title:string, slug:string}>>} 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",
@@ -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>|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<Response>} A wrapped handler.
*/
export function withAuth(callback, options = {}) {
const { requireAuth = true, requireAdmin = false } = options;
return async (request, ...args) => {
@@ -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>|Response} callback -
* Handler invoked with a request whose body is the validated payload.
* @returns {(req: NextRequest, res?: Response) => Promise<Response>} 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();