From 57fb8d8bb11c0e6abaf97d7cc652c5e21a4942f8 Mon Sep 17 00:00:00 2001
From: creations <creations@creations.works>
Date: Fri, 2 May 2025 18:41:10 -0400
Subject: [PATCH] add fast-jwt ( not tested ) and make the user actual register

---
 config/environment.ts       |  15 ++++-
 package.json                |   1 +
 src/lib/jwt.ts              | 121 ++++++++++++++++++++++++++++++++++++
 src/routes/user/login.ts    |   0
 src/routes/user/logout.ts   |   0
 src/routes/user/register.ts |  76 +++++++++++++++++-----
 types/config.d.ts           |   7 +++
 types/http.d.ts             |   9 +++
 types/logger.d.ts           |   9 ---
 types/tables/user.d.ts      |  16 +++++
 10 files changed, 227 insertions(+), 27 deletions(-)
 create mode 100644 src/lib/jwt.ts
 create mode 100644 src/routes/user/login.ts
 create mode 100644 src/routes/user/logout.ts
 delete mode 100644 types/logger.d.ts
 create mode 100644 types/tables/user.d.ts

diff --git a/config/environment.ts b/config/environment.ts
index 91ada96..32938f3 100644
--- a/config/environment.ts
+++ b/config/environment.ts
@@ -24,17 +24,30 @@ const cassandra: CassandraConfig = {
 	authEnabled: process.env.CASSANDRA_AUTH_ENABLED === "false",
 };
 
+const jwt: JWTConfig = {
+	secret: process.env.JWT_SECRET || "",
+	expiration: process.env.JWT_EXPIRATION || "1h",
+	issuer: process.env.JWT_ISSUER || "",
+	algorithm: process.env.JWT_ALGORITHM || "HS256",
+};
+
 function verifyRequiredVariables(): void {
 	const requiredVariables = [
 		"HOST",
 		"PORT",
+
 		"REDIS_URL",
 		"REDIS_TTL",
+
 		"CASSANDRA_HOST",
 		"CASSANDRA_PORT",
 		"CASSANDRA_CONTACT_POINTS",
 		"CASSANDRA_AUTH_ENABLED",
 		"CASSANDRA_DATACENTER",
+
+		"JWT_SECRET",
+		"JWT_EXPIRATION",
+		"JWT_ISSUER",
 	];
 
 	let hasError = false;
@@ -52,4 +65,4 @@ function verifyRequiredVariables(): void {
 	}
 }
 
-export { environment, cassandra, redisTtl, verifyRequiredVariables };
+export { environment, cassandra, redisTtl, verifyRequiredVariables, jwt };
diff --git a/package.json b/package.json
index 62d5f6b..215861a 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
 	"dependencies": {
 		"@creations.works/logger": "^1.0.3",
 		"cassandra-driver": "^4.8.0",
+		"fast-jwt": "^6.0.1",
 		"pika-id": "^1.1.3"
 	}
 }
diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts
new file mode 100644
index 0000000..09e22dc
--- /dev/null
+++ b/src/lib/jwt.ts
@@ -0,0 +1,121 @@
+import { environment, jwt } from "@config/environment";
+import { redis } from "bun";
+import { createDecoder, createSigner, createVerifier } from "fast-jwt";
+
+const signer = createSigner({ key: jwt.secret, expiresIn: jwt.expiration });
+const verifier = createVerifier({ key: jwt.secret });
+const decoder = createDecoder();
+
+export async function createSession(
+	payload: UserSession,
+	userAgent: string,
+): Promise<string> {
+	const token = signer(payload);
+	const sessionKey = `session:${payload.id}:${token}`;
+	await redis.set(sessionKey, JSON.stringify({ ...payload, userAgent }));
+	await redis.expire(sessionKey, getExpirationInSeconds());
+	return generateCookie(token);
+}
+
+export async function getSession(
+	request: Request,
+): Promise<UserSession | null> {
+	const token = extractToken(request);
+	if (!token) return null;
+	const keys = await redis.keys(`session:*:${token}`);
+	if (!keys.length) return null;
+	const raw = await redis.get(keys[0]);
+	return raw ? JSON.parse(raw) : null;
+}
+
+export async function updateSession(
+	request: Request,
+	payload: UserSession,
+	userAgent: string,
+): Promise<string> {
+	const token = extractToken(request);
+	if (!token) throw new Error("Session token not found");
+	const keys = await redis.keys(`session:*:${token}`);
+	if (!keys.length) throw new Error("Session not found or expired");
+	await redis.set(keys[0], JSON.stringify({ ...payload, userAgent }));
+	await redis.expire(keys[0], getExpirationInSeconds());
+	return generateCookie(token);
+}
+
+export async function verifySession(token: string): Promise<UserSession> {
+	const keys = await redis.keys(`session:*:${token}`);
+	if (!keys.length) throw new Error("Session not found or expired");
+	return verifier(token);
+}
+
+export async function decodeSession(token: string): Promise<UserSession> {
+	return decoder(token);
+}
+
+export async function invalidateSession(request: Request): Promise<void> {
+	const token = extractToken(request);
+	if (!token) return;
+	const keys = await redis.keys(`session:*:${token}`);
+	if (!keys.length) return;
+	await redis.del(keys[0]);
+}
+
+// helpers
+function extractToken(request: Request): string | null {
+	return request.headers.get("Cookie")?.match(/session=([^;]+)/)?.[1] || null;
+}
+
+function generateCookie(
+	token: string,
+	maxAge = getExpirationInSeconds(),
+	options?: {
+		secure?: boolean;
+		httpOnly?: boolean;
+		sameSite?: "Strict" | "Lax" | "None";
+		path?: string;
+		domain?: string;
+	},
+): string {
+	const {
+		secure = !environment.development,
+		httpOnly = true,
+		sameSite = environment.development ? "Lax" : "None",
+		path = "/",
+		domain,
+	} = options || {};
+
+	let cookie = `session=${encodeURIComponent(token)}; Path=${path}; Max-Age=${maxAge}`;
+	if (httpOnly) cookie += "; HttpOnly";
+	if (secure) cookie += "; Secure";
+	if (sameSite) cookie += `; SameSite=${sameSite}`;
+	if (domain) cookie += `; Domain=${domain}`;
+	return cookie;
+}
+
+function getExpirationInSeconds(): number {
+	const match = jwt.expiration.match(/^(\d+)([smhd])$/);
+	if (!match) throw new Error("Invalid expiresIn format in jwt config");
+	const [, value, unit] = match;
+	const num = Number(value);
+	switch (unit) {
+		case "s":
+			return num;
+		case "m":
+			return num * 60;
+		case "h":
+			return num * 3600;
+		case "d":
+			return num * 86400;
+		default:
+			throw new Error("Invalid time unit in expiresIn");
+	}
+}
+
+export const sessionManager = {
+	createSession,
+	getSession,
+	updateSession,
+	verifySession,
+	decodeSession,
+	invalidateSession,
+};
diff --git a/src/routes/user/login.ts b/src/routes/user/login.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/user/logout.ts b/src/routes/user/logout.ts
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/user/register.ts b/src/routes/user/register.ts
index 815ecd5..dc7e23e 100644
--- a/src/routes/user/register.ts
+++ b/src/routes/user/register.ts
@@ -1,3 +1,4 @@
+import { logger } from "@creations.works/logger";
 import { cassandra } from "@lib/cassandra";
 import { jsonResponse } from "@lib/http";
 import { pika } from "@lib/pika";
@@ -6,6 +7,7 @@ import {
 	isValidPassword,
 	isValidUsername,
 } from "@lib/validators";
+import type { Client } from "cassandra-driver";
 
 const routeDef: RouteDef = {
 	method: "POST",
@@ -19,9 +21,9 @@ async function handler(
 	requestBody: unknown,
 ): Promise<Response> {
 	const { username, password, email } = requestBody as {
-		username?: string;
-		password?: string;
-		email?: string;
+		username: string;
+		password: string;
+		email: string;
 	};
 
 	const fields = { username, password, email };
@@ -52,17 +54,23 @@ async function handler(
 		});
 	}
 
-	const usernameResult = await cassandra
-		.getClient()
-		.execute("SELECT id FROM users WHERE username = ?", [username], {
-			prepare: true,
-		});
+	const cassandraClient: Client = cassandra.getClient();
 
-	const emailResult = await cassandra
-		.getClient()
-		.execute("SELECT id FROM users WHERE email = ?", [email], {
+	const usernameResult = await cassandraClient.execute(
+		"SELECT id FROM users WHERE username = ?",
+		[username],
+		{
 			prepare: true,
-		});
+		},
+	);
+
+	const emailResult = await cassandraClient.execute(
+		"SELECT id FROM users WHERE email = ?",
+		[email],
+		{
+			prepare: true,
+		},
+	);
 
 	if (usernameResult.rowLength > 0 || emailResult.rowLength > 0) {
 		const errorMessages: string[] = [];
@@ -80,14 +88,48 @@ async function handler(
 		});
 	}
 
-	const userId = pika.gen("user");
+	const user: UserInsert = {
+		id: pika.gen("user"),
+		username,
+		email,
+		password: await Bun.password.hash(password),
+		created_at: new Date(),
+		updated_at: new Date(),
+	};
 
-	return jsonResponse(200, {
+	try {
+		await cassandraClient.execute(
+			"INSERT INTO users (id, username, email, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
+			[
+				user.id,
+				user.username,
+				user.email,
+				user.password,
+				user.created_at,
+				user.updated_at,
+			],
+			{ prepare: true },
+		);
+
+		logger.custom(
+			"[REGISTER]",
+			`(${user.id})`,
+			`${user.username} - ${user.email}`,
+			"34",
+		);
+	} catch (error) {
+		return jsonResponse(500, {
+			message: "Internal server error",
+			error: "Failed to register user",
+		});
+	}
+
+	return jsonResponse(201, {
 		message: "User registered successfully",
 		data: {
-			username,
-			email,
-			id: userId,
+			id: user.id,
+			username: user.username,
+			email: user.email,
 		},
 	});
 }
diff --git a/types/config.d.ts b/types/config.d.ts
index d826680..729f900 100644
--- a/types/config.d.ts
+++ b/types/config.d.ts
@@ -14,3 +14,10 @@ type CassandraConfig = {
 	contactPoints: string[];
 	authEnabled: boolean;
 };
+
+type JWTConfig = {
+	secret: string;
+	expiration: string;
+	issuer: string;
+	algorithm: string;
+};
diff --git a/types/http.d.ts b/types/http.d.ts
index 303fea2..17dc166 100644
--- a/types/http.d.ts
+++ b/types/http.d.ts
@@ -4,3 +4,12 @@ interface GenericJsonResponseOptions {
 	error?: string;
 	[key: string]: unknown;
 }
+
+interface UserSession {
+	id: string;
+	username: string;
+	email: string;
+	is_verified: boolean;
+	iat?: number; // issued at (added by JWT libs)
+	exp?: number; // expiration (added by JWT libs)
+}
diff --git a/types/logger.d.ts b/types/logger.d.ts
deleted file mode 100644
index ff6a601..0000000
--- a/types/logger.d.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-type ILogMessagePart = { value: string; color: string };
-
-type ILogMessageParts = {
-	level: ILogMessagePart;
-	filename: ILogMessagePart;
-	readableTimestamp: ILogMessagePart;
-	message: ILogMessagePart;
-	[key: string]: ILogMessagePart;
-};
diff --git a/types/tables/user.d.ts b/types/tables/user.d.ts
new file mode 100644
index 0000000..ac4a01b
--- /dev/null
+++ b/types/tables/user.d.ts
@@ -0,0 +1,16 @@
+type User = {
+	id: string;
+	username: string;
+	display_name: string;
+	email: string;
+	password: string;
+	avatar_url: string;
+	is_verified: boolean;
+	created_at: Date;
+	updated_at: Date;
+};
+
+type UserInsert = Pick<
+	User,
+	"id" | "username" | "email" | "password" | "created_at" | "updated_at"
+>;