Developer Guide

This guide explains how to work in the EduNest LMS codebase the right way. EduNest is a single Next.js 16 (App Router) application written in TypeScript and React 19, talking directly to PostgreSQL over @neondatabase/serverless (raw parameterized SQL — no ORM), with assets on Cloudflare R2. Follow the conventions below to keep the product consistent and resale-ready.

Tech Stack

Concern Choice
Framework Next.js 16 (App Router) + React 19
Language TypeScript 5
Database PostgreSQL via @neondatabase/serverless (raw SQL)
Auth Custom JWT (jose / jsonwebtoken) + bcryptjs, HttpOnly cookie
Secrets AES-256-GCM (Node crypto), key from SECRET_KEY
Storage Cloudflare R2 via @aws-sdk/client-s3
Styling Tailwind CSS 3 + lucide-react
Images sharp (resize/convert to WebP)

Local Setup

Terminal
npm install
cp .env.example .env        # then fill in DATABASE_URL, JWT_SECRET, etc.
npm run db:init             # bootstrap auth + vendor + LMS schema
npm run dev                 # start the dev server (Turbopack)

The dev server runs at http://localhost:3000. On localhost the license is bypassed, so the admin panel opens without activation.

npm scripts

Script Purpose
dev Start the Next.js dev server (Turbopack)
build Production build
start Run the production server
lint ESLint
type-check TypeScript check (no emit)
db:init Initialize the full schema (auth + vendor + LMS)
db:init:vendor Initialize the vendor/settings schema only
db:init:lms Initialize the LMS schema (courses, lessons, enrollments)
db:seed:core Seed core data
db:reset Reset the database to the template state
addons:bundle Regenerate the add-on aggregator indexes (npm run addons:bundle)
addons:catalog Regenerate docs/add-ons/catalog.json

Project Conventions

  • TypeScript everywhere; the @/ path alias maps to src/ — prefer it over deep relative paths.
  • Data access goes through the shared sql client in src/lib/db.ts (raw parameterized SQL). No ORM.
  • Every API route checks permissions with requirePermission(action, resource) before doing work.
  • Tables are created lazily by idempotent ensure*() functions called at the top of a route — there is no migration runner.
  • Secrets are encrypted with encryptSecret() before storage and masked in API responses; never return a plaintext key.
  • Server-only modules (db, secrets, anything importing Node crypto) must never be imported into client components.

The core / product Layer Split

EduNest is built to power many products from one architecture, so code is split into two layers:

Code
src/core/      Reusable integration infrastructure (channels, adapters, plugins)
src/product/   Product-specific identity, navigation, monetization, and add-ons
src/lib/       Shared utilities (db, auth, secrets, schema bootstrap, helpers)
src/app/       Next.js routes (storefront, /admin, /learner, /instructor, /learn, /api)
  • src/core/ holds the technical machinery: the channels registry and provider types (channels/), runtime adapters (adapters/), and the feature-plugin system (plugins/types.ts, plugins/state.ts, plugins/registry.ts, plugins/hooks.ts).
  • src/product/ holds what makes this product itself: product.ts (PRODUCT_ID = "edunest-lms"), the data-driven navs (admin-nav.ts, learner-nav.ts, instructor-nav.ts), checkout/payout config, and addons/ — the 93 built-in add-ons.

Add-ons register into core through generated aggregator indexes (ADDON_CHANNELS, ADDON_FEATURES, server/client adapters). See the Add-on and Plugin Development guides.

Data Access (raw SQL + ensure-schema)

The single database client lives in src/lib/db.ts:

TS
import sql, { rawSql } from "@/lib/db";

// Tagged-template form (preferred) — values are parameterized, not interpolated.
const rows = await sql`SELECT * FROM courses WHERE id = ${courseId}`;

// Method form for dynamic SQL.
const r = await sql.query("SELECT * FROM courses WHERE id = $1", [courseId]);

// rawSql helper for parameterized non-template queries.
const list = await rawSql("SELECT * FROM courses WHERE status = $1", ["published"]);

type Row = Record<string, unknown> is also exported. The client initializes lazily on first use so next build doesn't fail when DATABASE_URL is absent.

The ensure-schema pattern

There is no migration tool. Each table is created by an idempotent, guarded ensure*() function that's called at the start of any route that touches it. Example from src/lib/lms-schema.ts:

TS
let instructorsReady = false;
export async function ensureInstructors(): Promise<void> {
  if (instructorsReady) return;
  await ensureCustomers();                 // dependencies first
  await sql`
    CREATE TABLE IF NOT EXISTS instructors (
      id                SERIAL PRIMARY KEY,
      customer_id       INT UNIQUE NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
      status            VARCHAR(20) DEFAULT 'pending',
      revenue_share_pct NUMERIC DEFAULT 70,
      created_at        TIMESTAMPTZ DEFAULT NOW()
    )
  `;
  instructorsReady = true;
}

The boolean flag makes the call cheap after the first run; CREATE TABLE IF NOT EXISTS and ADD COLUMN IF NOT EXISTS make it safe to call repeatedly and on older databases. The auth schema (src/lib/auth-schema.ts) follows the same pattern and seeds the first admin user from ADMIN_EMAIL / ADMIN_PASSWORD.

Auth Helpers & Sessions

JWTs are signed and verified in src/lib/auth.ts using jose (HS256) with the secret from JWT_SECRET, stored in the HttpOnly cookie admin_token:

TS
export async function signToken(payload: JWTPayload): Promise<string>;   // 7d default
export async function verifyToken(token: string): Promise<JWTPayload | null>;
export async function getSession(): Promise<JWTPayload | null>;          // reads the cookie

Authorization is enforced per route by requirePermission() in src/lib/require-permission.ts:

TS
export type PermAction = "read" | "create" | "update" | "delete";

export async function requirePermission(
  action: PermAction, resource: string,
): Promise<Response | null>;   // returns a 403 Response, or null when allowed

Super roles (e.g. VENDOR_ADMIN, SUPERADMIN) bypass checks; everyone else is matched against their role's permissions array of "<resource>:<action>" strings. Authorized mutations also emit an admin.action core event so an audit plugin can log them — without ever blocking the route. Learner-facing routes resolve the current customer via getCustomerId() (src/lib/customer-auth.ts).

Adding an API Route

API routes live under src/app/api/... and export GET / POST / PUT / DELETE handlers. Follow this order: permission → ensure schema → query → mask secrets → respond.

TS
import { NextRequest } from "next/server";
import sql from "@/lib/db";
import { requirePermission } from "@/lib/require-permission";
import { ensureCourses } from "@/lib/lms-schema";
import { ok, serverErr } from "@/lib/api-helpers";

export async function GET() {
  const denied = await requirePermission("read", "courses");
  if (denied) return denied;
  try {
    await ensureCourses();
    const rows = await sql`SELECT * FROM courses ORDER BY created_at DESC`;
    return ok(rows);
  } catch (e) {
    return serverErr(e);
  }
}

src/lib/api-helpers.ts provides ok(), err(message, status), and serverErr(e) for consistent JSON responses.

Secrets / Encryption

src/lib/secrets.ts provides AES-256-GCM helpers. The key is the SHA-256 hash of SECRET_KEY (falling back to JWT_SECRET):

TS
encryptSecret(plain: string): string;          // "enc:v1:" + base64(iv || tag || ciphertext)
decryptSecret(value: string): string;
encryptJson(obj: unknown): string;
decryptJson<T>(value: unknown): T;

When a settings field holds a secret (an API key, a payment secret), encrypt it on write and return a mask (••••••••) on read so plaintext never leaves the server.

Environment Variables

Variable Purpose
DATABASE_URL Neon PostgreSQL connection string (required)
JWT_SECRET HMAC secret for signing JWTs (required)
JWT_EXPIRES_IN Token lifetime (default 7d)
SECRET_KEY Source for the AES-256-GCM key (falls back to JWT_SECRET)
NEXT_PUBLIC_SITE_URL Public base URL
ADMIN_EMAIL / ADMIN_PASSWORD First-run admin bootstrap
NODE_ENV production enables secure cookies
LICENSE_SERVER_URL / LICENSE_PUBLIC_KEY License verification (optional overrides)
NOTIFY_EMAIL / NOTIFY_EMAIL sender Notification sender

Provider credentials (Cloudflare R2, Stripe, SendGrid, etc.) are not kept in .env — admins enter them in Settings → Integrations and they are stored encrypted in the database.


© CreativeCape Solutions · creative-cape.com · support@creative-cape.com