generated from nhcarrigan/template
29c817230d
## Summary This PR represents the full v1 prototype, implementing the core game systems for Elysium. - Full idle/clicker RPG loop: resource collection, crafting, boss fights, exploration, and quests - Adventurer hiring with batch size selector and progressive tier cost scaling - Prestige, transcendence, and apotheosis systems with auto-prestige support - Character sheet, titles, leaderboards, companion system, and daily login bonuses - Auto-quest and auto-boss toggles - Discord webhook notifications on prestige/transcendence/apotheosis - Discord role awarded on apotheosis - Responsive design and overarching story/lore system - In-game sound effects and browser notifications for key events - Support link button in the resource bar - Full test coverage (100% on `apps/api` and `packages/types`) - CI pipeline: lint → build → test ## Closes Closes #1 Closes #2 Closes #3 Closes #4 Closes #5 Closes #6 Closes #7 Closes #8 Closes #9 Closes #10 Closes #11 Closes #12 Closes #13 Closes #14 Closes #16 Closes #19 Closes #20 Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #26 Closes #27 Closes #29 ✨ This issue was created with help from Hikari~ 🌸 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Reviewed-on: #30 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
93 lines
2.8 KiB
TypeScript
93 lines
2.8 KiB
TypeScript
/**
|
|
* @file JWT token signing and verification utilities.
|
|
* @copyright nhcarrigan
|
|
* @license Naomi's Public License
|
|
* @author Naomi Carrigan
|
|
*/
|
|
import { createHmac } from "node:crypto";
|
|
|
|
interface JwtPayload {
|
|
discordId: string;
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
const base64UrlEncode = (data: string): string => {
|
|
return Buffer.from(data).toString("base64url");
|
|
};
|
|
|
|
const base64UrlDecode = (data: string): string => {
|
|
return Buffer.from(data, "base64url").toString("utf8");
|
|
};
|
|
|
|
/**
|
|
* Signs a JWT token for the given Discord ID.
|
|
* @param discordId - The Discord user ID to encode in the token.
|
|
* @returns A signed JWT string valid for 30 days.
|
|
* @throws {Error} If the JWT_SECRET environment variable is not set.
|
|
*/
|
|
const signToken = (discordId: string): string => {
|
|
const secret = process.env.JWT_SECRET;
|
|
if (secret === undefined || secret === "") {
|
|
throw new Error("JWT_SECRET environment variable is required");
|
|
}
|
|
|
|
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
// 30 days expiry
|
|
const thirtyDaysInSeconds = 60 * 60 * 24 * 30;
|
|
const payload = base64UrlEncode(
|
|
JSON.stringify({
|
|
discordId: discordId,
|
|
exp: Math.floor(Date.now() / 1000) + thirtyDaysInSeconds,
|
|
iat: Math.floor(Date.now() / 1000),
|
|
}),
|
|
);
|
|
|
|
const signature = createHmac("sha256", secret).
|
|
update(`${header}.${payload}`).
|
|
digest("base64url");
|
|
|
|
return `${header}.${payload}.${signature}`;
|
|
};
|
|
|
|
/**
|
|
* Verifies a JWT token and returns the decoded payload.
|
|
* @param token - The JWT string to verify.
|
|
* @returns The decoded JWT payload containing discordId, iat, and exp.
|
|
* @throws {Error} If the JWT_SECRET is missing, the token is malformed, the
|
|
* signature is invalid, or the token has expired.
|
|
*/
|
|
const verifyToken = (token: string): JwtPayload => {
|
|
const secret = process.env.JWT_SECRET;
|
|
if (secret === undefined || secret === "") {
|
|
throw new Error("JWT_SECRET environment variable is required");
|
|
}
|
|
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) {
|
|
throw new Error("Invalid token format");
|
|
}
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Array destructure of known-length tuple */
|
|
const [ header, payload, signature ] = parts as [string, string, string];
|
|
|
|
const expectedSignature = createHmac("sha256", secret).
|
|
update(`${header}.${payload}`).
|
|
digest("base64url");
|
|
|
|
if (signature !== expectedSignature) {
|
|
throw new Error("Invalid token signature");
|
|
}
|
|
|
|
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Parsed JSON from trusted base64url payload */
|
|
const decoded = JSON.parse(base64UrlDecode(payload)) as JwtPayload;
|
|
|
|
if (decoded.exp < Math.floor(Date.now() / 1000)) {
|
|
throw new Error("Token has expired");
|
|
}
|
|
|
|
return decoded;
|
|
};
|
|
|
|
export { signToken, verifyToken };
|