Documentation Index Fetch the complete documentation index at: https://docs.sunschool.xyz/llms.txt
Use this file to discover all available pages before exploring further.
Authentication System
Sunschool uses JWT (JSON Web Token) authentication with scrypt password hashing. From server/middleware/auth.ts:
import jwt from 'jsonwebtoken' ;
import { scrypt , randomBytes , timingSafeEqual } from 'crypto' ;
import { promisify } from 'util' ;
const scryptAsync = promisify ( scrypt );
const JWT_SECRET = process . env . JWT_SECRET || 'sunschool-secure-jwt-secret-for-development' ;
const JWT_EXPIRES_IN = '7d' ; // 7 days
JWT Configuration
JWT tokens provide stateless authentication, allowing the server to verify user identity without session storage.
Token Generation
From server/middleware/auth.ts:
export function generateToken ( user : { id : string | number , role : string }) : string {
const payload : JwtPayload = {
userId: String ( user . id ),
role: user . role
};
try {
const token = jwt . sign ( payload , JWT_SECRET , { expiresIn: JWT_EXPIRES_IN });
return token ;
} catch ( error ) {
console . error ( 'Failed to generate token:' , error );
throw error ;
}
}
Configuration:
JWT_SECRET="your-secure-random-secret-min-32-chars"
JWT_EXPIRY=7d # Optional: 7d, 30d, 1h, etc.
From server/config/env.ts:
export const JWT_SECRET = getEnv ( 'JWT_SECRET' , SESSION_SECRET );
export const JWT_EXPIRY = getEnv ( 'JWT_EXPIRY' , '7d' );
Token Verification
From server/middleware/auth.ts:
export async function authenticateJwt ( req : AuthRequest , res : Response , next : NextFunction ) : Promise < void > {
let token : string | undefined ;
// Try Authorization header first (most common)
const authHeader = req . headers . authorization ;
if ( authHeader ) {
const parts = authHeader . split ( ' ' );
if ( parts . length === 2 && parts [ 0 ] === 'Bearer' ) {
token = parts [ 1 ];
}
}
// Check for custom header (for sunschool.xyz domain)
if ( ! token && req . headers [ 'x-sunschool-auth-token' ]) {
token = req . headers [ 'x-sunschool-auth-token' ] as string ;
}
// Check cookies
if ( ! token && req . cookies && req . cookies . token ) {
token = req . cookies . token ;
}
if ( ! token ) {
res . status ( 401 ). json ({ error: 'No authorization token provided' });
return ;
}
try {
const payload = verifyToken ( token );
const user = await storage . getUser ( payload . userId );
if ( ! user ) {
res . status ( 401 ). json ({ error: 'User not found' });
return ;
}
req . user = user ;
next ();
} catch ( error ) {
if ( error instanceof jwt . TokenExpiredError ) {
res . status ( 401 ). json ({ error: 'Token expired' });
} else {
res . status ( 401 ). json ({ error: 'Invalid token' });
}
}
}
Generate Secure Secrets
Never use default secrets in production. Generate cryptographically secure random values.
Generate JWT secret:
# Using OpenSSL (recommended)
openssl rand -base64 32
# Output: XwK9vL2mN8pQ5rT6uY7zA1bC3dE4fG5hIjK6lM7nO8p
# Using Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
# Output: A1bC2dE3fG4hI5jK6lM7nO8pQ9rS0tU1vW2xY3zA4b
# Using Python
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
Add to .env:
JWT_SECRET="XwK9vL2mN8pQ5rT6uY7zA1bC3dE4fG5hIjK6lM7nO8pQ9rS0tU1vW2xY3zA4b"
SESSION_SECRET="A1bC2dE3fG4hI5jK6lM7nO8pQ9rS0tU1vW2xY3zA4bC5dE6fG7hI8jK9lM0n"
If JWT_SECRET is not set, it defaults to SESSION_SECRET. Set both separately for better security.
Password Security
scrypt Hashing
Sunschool uses scrypt for password hashing - a memory-hard function resistant to brute-force attacks.
From server/middleware/auth.ts:
export async function hashPassword ( password : string ) : Promise < string > {
const salt = randomBytes ( 16 ). toString ( "hex" );
const buf = ( await scryptAsync ( password , salt , 64 )) as Buffer ;
return ` ${ buf . toString ( "hex" ) } . ${ salt } ` ;
}
export async function comparePasswords ( supplied : string , stored : string ) : Promise < boolean > {
if ( ! stored || ! stored . includes ( '.' )) {
console . error ( 'Invalid stored password format' );
return false ;
}
const [ hashed , salt ] = stored . split ( "." );
const hashedBuf = Buffer . from ( hashed , "hex" );
const suppliedBuf = ( await scryptAsync ( supplied , salt , 64 )) as Buffer ;
return timingSafeEqual ( hashedBuf , suppliedBuf );
}
Security features:
Random 16-byte salt per password
64-byte derived key using scrypt
Timing-safe comparison to prevent timing attacks
Format: {hash_hex}.{salt_hex}
Password Requirements
Implement password requirements in your client application. The server accepts any non-empty password.
Recommended client-side validation:
function validatePassword ( password ) {
return (
password . length >= 8 &&
/ [ A-Z ] / . test ( password ) && // Uppercase
/ [ a-z ] / . test ( password ) && // Lowercase
/ [ 0-9 ] / . test ( password ) // Digit
);
}
Role-Based Access Control
From shared/schema.ts:
export const userRoleEnum = pgEnum ( "user_role" , [ "ADMIN" , "PARENT" , "LEARNER" ]);
User Roles
Permissions:
Full system access
View all users and data
Manage parents and learners
Access admin-only endpoints
Export all data
Use case: System administrators, school administratorsFrom server/auth.ts: // First user auto-promoted to ADMIN
const userCountResult = await db . select ({ count: count () }). from ( users );
const isFirstUser = userCountResult [ 0 ]. count === 0 ;
const effectiveRole = isFirstUser ? "ADMIN" : role ;
Permissions:
Manage own children (learners)
View children’s progress and lessons
Create/delete learner accounts
Configure rewards and goals
Export own family data
Restrictions:
Cannot access other families’ data
Cannot promote users to admin
From server/routes.ts: // Parents scoped to their children
if ( req . user ?. role === "PARENT" ) {
const children = await storage . getUsersByParentId ( req . user . id );
if ( ! children . some ( child => child . id . toString () === learnerId . toString ())) {
return res . status ( 403 ). json ({ error: "Forbidden" });
}
}
Permissions:
Access own lessons and quizzes
View own progress and achievements
Redeem rewards (with parent approval)
Update own profile (limited fields)
Restrictions:
Cannot access other learners’ data
Cannot manage users
Cannot configure system settings
Note: Learners typically don’t have passwords (parent-managed accounts).
Role Middleware
From server/middleware/auth.ts:
export function hasRoleMiddleware ( roles : string []) {
return ( req : AuthRequest , res : Response , next : NextFunction ) : void => {
if ( ! req . user ) {
res . status ( 401 ). json ({ error: 'Unauthorized' });
return ;
}
if ( ! roles . includes ( req . user . role )) {
res . status ( 403 ). json ({ error: 'Forbidden' });
return ;
}
next ();
};
}
Usage in routes:
// Admin-only endpoint
app . get ( "/api/parents" , hasRole ([ "ADMIN" ]), asyncHandler ( async ( req , res ) => {
const parents = await storage . getAllParents ();
res . json ( parents );
}));
// Parent or Admin
app . get ( "/api/learners" , hasRole ([ "PARENT" , "ADMIN" ]), asyncHandler ( async ( req , res ) => {
// ...
}));
// Any authenticated user
app . get ( "/api/lessons/active" , isAuthenticated , asyncHandler ( async ( req , res ) => {
// ...
}));
From server/auth.ts and server/routes.ts:
// Check if this is the first user being registered
const userCountResult = await db . select ({ count: count () }). from ( users );
const isFirstUser = userCountResult [ 0 ]. count === 0 ;
// If this is the first user, make them an admin regardless of requested role
const effectiveRole = isFirstUser ? "ADMIN" : role ;
const user = await storage . createUser ({
username ,
email ,
name ,
role: effectiveRole ,
password: hashedPassword ,
});
First registered user becomes ADMIN automatically. This ensures you can bootstrap the system without manual database access.
Registration response includes promotion notice:
{
"token" : "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." ,
"user" : {
"id" : 1 ,
"username" : "admin" ,
"role" : "ADMIN"
},
"wasPromotedToAdmin" : true
}
Session Security
From server/config/env.ts:
export const SESSION_SECRET = getEnv ( 'SESSION_SECRET' , 'dev-secret-change-me' );
Session configuration:
SESSION_SECRET="your-session-secret-different-from-jwt"
Use different secrets for JWT and sessions to limit the impact of a potential key compromise.
Session Storage
From shared/schema.ts, sessions are stored in PostgreSQL:
export const sessions = pgTable (
"sessions" ,
{
sid: varchar ( "sid" ). primaryKey (),
sess: jsonb ( "sess" ). notNull (),
expire: timestamp ( "expire" ). notNull (),
},
( table ) => [ index ( "IDX_session_expire" ). on ( table . expire )],
);
Automatic cleanup: Expired sessions are periodically removed via the expire index.
CORS Configuration
From server/auth.ts:
const origin = req . headers . origin || req . headers . referer || 'unknown' ;
let isSunschool = false ;
try {
const parsedOrigin = new URL ( origin );
isSunschool = parsedOrigin . hostname === 'sunschool.xyz' ||
parsedOrigin . hostname . endsWith ( '.sunschool.xyz' );
} catch ( _e ) { /* ignore invalid origin URL */ }
if ( isSunschool ) {
res . header ( 'Access-Control-Allow-Origin' , origin );
res . header ( 'Access-Control-Allow-Credentials' , 'true' );
res . header ( 'Access-Control-Allow-Methods' , 'GET,PUT,POST,DELETE,OPTIONS' );
res . header ( 'Access-Control-Allow-Headers' , 'Content-Type,Authorization,X-Sunschool-Auth,X-Sunschool-Auth-Token' );
}
SAST finding (MEDIUM priority): CORS origin uses substring match. For production, tighten to exact domain match.
Recommended production CORS:
const allowedOrigins = [
'https://sunschool.xyz' ,
'https://www.sunschool.xyz' ,
'https://app.sunschool.xyz'
];
if ( allowedOrigins . includes ( origin )) {
res . header ( 'Access-Control-Allow-Origin' , origin );
res . header ( 'Access-Control-Allow-Credentials' , 'true' );
}
API Security Best Practices
Rate Limiting
Not implemented by default. Add rate limiting for production deployments.
Recommended: express-rate-limit
npm install express-rate-limit
import rateLimit from 'express-rate-limit' ;
const limiter = rateLimit ({
windowMs: 15 * 60 * 1000 , // 15 minutes
max: 100 , // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP'
});
app . use ( '/api/' , limiter );
Current validation:
if ( ! username || ! password ) {
return res . status ( 400 ). json ({ error: "Username and password are required" });
}
if ( ! gradeLevel || ! learnerId ) {
return res . status ( 400 ). json ({ error: "Missing required fields" });
}
Recommended: Add schema validation with Zod
import { z } from 'zod' ;
const loginSchema = z . object ({
username: z . string (). min ( 3 ). max ( 50 ),
password: z . string (). min ( 8 )
});
try {
const { username , password } = loginSchema . parse ( req . body );
} catch ( error ) {
return res . status ( 400 ). json ({ error: error . errors });
}
SQL Injection Protection
Already protected. Sunschool uses Drizzle ORM with parameterized queries.
From server/routes.ts:
// Safe: Uses parameterized query via Drizzle
const user = await storage . getUserByUsername ( username );
// Safe: Uses prepared statements
const result = await db . select (). from ( users ). where ( eq ( users . id , userId ));
Avoid raw SQL unless necessary:
// Unsafe
const query = `SELECT * FROM users WHERE username = ' ${ username } '` ;
// Safe (if raw SQL needed)
const query = await pool . query (
'SELECT * FROM users WHERE username = $1' ,
[ username ]
);
XSS Protection
From ENGINEERING.md:
SVG content is sanitized via a lightweight regex-based sanitizer in server/services/svg-llm-service.ts
SVG sanitization:
Strips <script>, <style>, <iframe> tags
Removes event handlers (onclick, onload, etc.)
Blocks javascript: and data: URIs
LLM prompts prohibit scripts and external references
SAST finding (LOW priority): SVG innerHTML rendering. Server-side DOMPurify mitigates; consider client-side pass.
Environment-Specific Security
Development
NODE_ENV=development
DATABASE_SSL=false
JWT_SECRET="dev-jwt-secret-not-for-production"
SESSION_SECRET="dev-session-secret"
Relaxed settings:
Detailed error messages
No SSL for local database
Shorter JWT expiry for testing
Production
NODE_ENV=production
DATABASE_SSL=true
JWT_SECRET="<32+ char secure random string>"
SESSION_SECRET="<different 32+ char secure string>"
JWT_EXPIRY=7d
Hardened settings:
Generic error messages
SSL required for all connections
Long random secrets
Extended token expiry for user convenience
Security Checklist
Generate Secure Secrets
openssl rand -base64 32 # JWT_SECRET
openssl rand -base64 32 # SESSION_SECRET
Enable SSL for Database
DATABASE_URL="postgresql://...?sslmode=require"
DATABASE_SSL=true
Set Strong Password Policy
Implement client-side validation:
Minimum 8 characters
Mixed case, numbers, symbols
Configure CORS Properly
Use exact origin matching, not substrings
Add Rate Limiting
Install and configure express-rate-limit
Keep Dependencies Updated
Audit Logging
Not implemented by default. Add audit logging for production.
Recommended approach:
// Create audit_logs table
export const auditLogs = pgTable ( "audit_logs" , {
id: uuid ( "id" ). defaultRandom (). primaryKey (),
userId: integer ( "user_id" ). references (() => users . id ),
action: text ( "action" ). notNull (),
resource: text ( "resource" ),
details: jsonb ( "details" ),
ipAddress: text ( "ip_address" ),
userAgent: text ( "user_agent" ),
createdAt: timestamp ( "created_at" ). defaultNow (),
});
// Log critical actions
await db . insert ( auditLogs ). values ({
userId: req . user . id ,
action: 'LOGIN' ,
ipAddress: req . ip ,
userAgent: req . headers [ 'user-agent' ]
});
Next Steps
Database Setup Secure your database with SSL and access controls
Monitoring Monitor authentication failures and security events
Troubleshooting Debug authentication issues