Slight restructure, updated auth, implement player and game endpoints

This commit is contained in:
jd
2026-02-18 21:32:28 +00:00
parent 99c7bdc0fd
commit 2996a2eb95
32 changed files with 2093 additions and 266 deletions

View File

@@ -1,31 +1,103 @@
import {orm} from "../orm/orm";
import jwt from "jsonwebtoken";
import {UnwrappedRequest} from "../utilities/guard";
import {ErrorResponse} from "../utilities/responseHelper";
import {Claims} from "../orm/claims";
import {UnauthorizedError} from "../utilities/errors";
import { orm } from '../orm/orm';
import jwt from 'jsonwebtoken';
import { UnwrappedRequest } from '../utilities/guard';
import { ErrorResponse, OkResponse, UnauthorizedResponse } from '../utilities/responseHelper';
import { Claims } from '../orm/claims';
import { UnauthorizedError } from '../utilities/errors';
import { ChangePasswordRequest, LoginRequest, SecureId } from '../utilities/requestModels';
async function login(request: UnwrappedRequest): Promise<Response> {
try {
const requestBody = request.json;
console.log(`/api/auth/login: username=${requestBody.username}`);
const claims: Claims | null = await orm.users.verify(requestBody.username, requestBody.password);
console.log(claims);
if (claims) {
const token = jwt.sign({...claims}, process.env.JWT_SECRET_KEY as string, {expiresIn: "24h"});
return Response.json({token: token, claims: claims}, {status: 200});
const requestBody = request.body as LoginRequest;
const verify: {
userId: SecureId;
refreshCount: string;
} | null = await orm.users.verifyCredentials(requestBody.username, requestBody.password);
if (!verify) {
throw new UnauthorizedError('Invalid credentials');
}
throw new UnauthorizedError('Invalid credentials');
// Build refresh token that expires in 30 days, return as secure HTTP only cookie.
const tokenLifeSpanInDays = 30;
const token = jwt.sign(
{
u: verify.userId.raw,
r: verify.refreshCount,
},
process.env.JWT_SECRET_KEY as string,
{ expiresIn: `${tokenLifeSpanInDays * 24}h` },
);
const cookies = request?.request?.cookies;
cookies?.set({
name: 'refresh',
value: token,
httpOnly: true,
secure: true,
maxAge: tokenLifeSpanInDays * 24 * 60 * 60,
});
return new OkResponse();
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function test(request: UnwrappedRequest) {
return Response.json(request.claims, {status: 200});
async function token(request: UnwrappedRequest): Promise<Response> {
try {
const cookies = request.request.cookies;
const refreshCookie = cookies.get('refresh');
if (!refreshCookie) {
throw new UnauthorizedError('No refresh token found');
}
const refreshToken: {
u: string;
r: string;
} = jwt.verify(refreshCookie, process.env.JWT_SECRET_KEY as string) as { u: string; r: string };
if (!(await orm.users.verifyRefreshCount(SecureId.fromID(refreshToken.u), refreshToken.r))) {
const response = new UnauthorizedResponse('Invalid refresh token');
response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"');
return response;
}
const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u);
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' });
return new OkResponse({ token });
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function logout(request: UnwrappedRequest): Promise<Response> {
try {
const response = new OkResponse();
response.headers.set('Clear-Site-Data', '"cookies","cache","storage","executionContexts"');
return response;
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function changePassword(request: UnwrappedRequest): Promise<Response> {
try {
const requestBody = request.body as ChangePasswordRequest;
return new OkResponse(
await orm.users.changePassword(
SecureId.fromHash(request.params.id),
requestBody.oldPassword,
requestBody.newPassword,
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
login,
test
};
token,
logout,
changePassword,
};

70
src/endpoints/games.ts Normal file
View File

@@ -0,0 +1,70 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import {
CreateGameRequest,
SecureId,
UpdateGameRequest,
} from '../utilities/requestModels';
async function create(request: UnwrappedRequest<CreateGameRequest>): Promise<Response> {
try {
const newUser = await orm.games.create(
{
name: request.body.name,
bggId: request.body.bggId,
imagePath: request.body.imagePath,
},
request.claims,
);
return new CreatedResponse(newUser);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.get(SecureId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateGameRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.games.update(
SecureId.fromHash(request.params.id),
request.body,
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.drop(SecureId.fromHash(request.params.id)));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function query(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.games.query(request.params.query));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
update,
drop,
query,
};

46
src/endpoints/player.ts Normal file
View File

@@ -0,0 +1,46 @@
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import { CreatePlayerRequest, SecureId, UpdatePlayerRequest } from '../utilities/requestModels';
async function create(request: UnwrappedRequest<CreatePlayerRequest>): Promise<Response> {
try {
const newPlayer = await orm.players.create(request.body, request.claims);
return new CreatedResponse(newPlayer);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.players.get(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.players.update(SecureId.fromHash(request.params.id), request.body, request.claims),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.players.drop(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
export default {
create,
get,
update,
drop,
};

View File

@@ -1,22 +1,18 @@
import {orm} from "../orm/orm";
import {UnwrappedRequest} from "../utilities/guard";
import {ErrorResponse} from "../utilities/responseHelper";
import { orm } from '../orm/orm';
import { UnwrappedRequest } from '../utilities/guard';
import { CreatedResponse, ErrorResponse, OkResponse } from '../utilities/responseHelper';
import { CreateUserRequest, SecureId, UpdateUserRequest } from '../utilities/requestModels';
async function create(request: UnwrappedRequest): Promise<Response> {
async function create(request: UnwrappedRequest<CreateUserRequest>): Promise<Response> {
try {
const requestBody = request.json;
const newUser = await orm.users.create(requestBody.username, requestBody.password, request.claims);
if(!newUser) {
return new Response(null,{status: 201})
}
return Response.json(
const newUser = await orm.users.create(
{
...newUser
...request.body,
playerId: SecureId.fromHash(request.body.playerId),
},
{status: 201}
request.claims,
);
return new CreatedResponse(newUser);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
@@ -24,9 +20,29 @@ async function create(request: UnwrappedRequest): Promise<Response> {
async function get(request: UnwrappedRequest): Promise<Response> {
try {
return Response.json({
...(await orm.users.get(request.params.id, request.claims))
}, {status: 200});
return new OkResponse(await orm.users.get(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> {
try {
return new OkResponse(
await orm.users.update(
SecureId.fromHash(request.params.id),
request.body,
request.claims,
),
);
} catch (error: any) {
return new ErrorResponse(error as Error);
}
}
async function drop(request: UnwrappedRequest): Promise<Response> {
try {
return new OkResponse(await orm.users.drop(SecureId.fromHash(request.params.id), request.claims));
} catch (error: any) {
return new ErrorResponse(error as Error);
}
@@ -35,4 +51,6 @@ async function get(request: UnwrappedRequest): Promise<Response> {
export default {
create,
get,
}
update,
drop,
};

View File

@@ -1,27 +1,26 @@
import {unwrapMethod, guard} from './utilities/guard';
import auth from "./endpoints/auth";
import user from "./endpoints/user";
import auth from './routes/auth';
import user from './routes/user';
import player from './routes/player';
import game from './routes/game';
import { OkResponse } from './utilities/responseHelper';
const server = Bun.serve({
routes: {
"/api/auth/login": {
POST: unwrapMethod(auth.login),
},
"/api/auth/test": {
GET: guard(auth.test, ['ADMIN', 'USERS_OTHER_DELETE'])
},
"/api/user": {
POST: guard(user.create, ['ADMIN', 'USERS_CREATE'])
},
"/api/user/:id": {
GET: guard(user.get, ['ADMIN', 'USERS_OTHERS_READ', 'USERS_SELF_READ'])
...auth,
...user,
...player,
...game,
'/test': {
GET: (request) => {
return new OkResponse();
},
},
},
// (optional) fallback for unmatched routes:
fetch(): Response {
return Response.json({message: "Not found"}, {status: 404});
return Response.json({ message: 'Not found' }, { status: 404 });
},
});
console.log(`Server running at ${server.url}`);
console.log(`Server running at ${server.url}`);

View File

@@ -1,11 +1,12 @@
import {sql} from 'bun';
import { sql } from 'bun';
import { ClaimDefinition } from '../utilities/claimDefinitions';
export class Claims {
userId: string | undefined;
export class Claims extends ClaimDefinition {
userId?: string;
claims: string[] = [];
public static test(userClaims: Claims, guardClaim: string): Boolean {
return userClaims.claims.some(x => x === guardClaim);
public static test(guardClaim: string, userClaims?: Claims): Boolean {
return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim);
}
}
@@ -13,11 +14,11 @@ export class ClaimsOrm {
async getByUserId(userId: string): Promise<Claims> {
const dbResults: any[] = await sql`SELECT c.name
from user_claims as uc
JOIN claims as c on uc.claimid = c.id
where uc.userid = ${userId};`;
JOIN claims as c on uc.claim_id = c.id
where uc.user_id = ${userId};`;
const claims = new Claims();
claims.userId = userId;
claims.claims = dbResults.map(x => x.name);
claims.claims = dbResults.map((x) => x.name);
return claims;
}
@@ -25,6 +26,6 @@ export class ClaimsOrm {
const dbResults: any[] = await sql`SELECT id
FROM claims
WHERE is_default = true;`;
return dbResults.map(x => x.id);
return dbResults.map((x) => x.id);
}
}
}

125
src/orm/games.ts Normal file
View File

@@ -0,0 +1,125 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first, memoize } from 'lodash';
import { NotFoundError, UnauthorizedError } from '../utilities/errors';
import { CreateGameRequest, SecureId, UpdateGameRequest } from '../utilities/requestModels';
import { memo } from '../utilities/helpers';
export class Game {
id: SecureId;
name: string;
imagePath?: string;
bggId?: string;
constructor(input: { id: SecureId; name: string; imagePath?: string; bggId?: string }) {
this.id = input.id;
this.name = input?.name;
this.imagePath = input?.imagePath;
this.bggId = input?.bggId;
}
}
export class GamesOrm {
async create(model: CreateGameRequest, claims?: Claims): Promise<Game | null> {
await sql`INSERT INTO games (name, image_path, bgg_id)
VALUES (${model.name}, ${Claims.test(Claims.GAMES.MANAGE_IMAGES, claims) ? model.imagePath : null}, ${model.bggId})`;
const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
try {
return await this.get(SecureId.fromID(newGameId));
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async get(id: SecureId): Promise<Game> {
const dbResult: any = first(
await sql`SELECT *
FROM games
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching game exists');
}
return new Game({
id: SecureId.fromID(dbResult.id),
name: dbResult.name,
bggId: dbResult.bgg_id,
imagePath: dbResult.image_path,
});
}
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game | null> {
const gameToUpdate = await this.get(id);
gameToUpdate.name = patch.name ?? gameToUpdate.name;
gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId;
if (Claims.test(Claims.GAMES.MANAGE_IMAGES, claims)) {
gameToUpdate.imagePath = patch.imagePath ?? gameToUpdate.imagePath;
}
await sql`UPDATE games
SET name=${gameToUpdate.name},
bgg_id=${gameToUpdate.bggId},
image_path=${gameToUpdate.imagePath}
WHERE id = ${id.raw}`;
try {
return await this.get(id);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async drop(id: SecureId): Promise<undefined> {
// Ensure player exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {
await tx`DELETE
FROM collection_games
WHERE game_id = ${id.raw}`;
await tx`DELETE
FROM match_players
WHERE match_id IN (SELECT id FROM matches WHERE game_id = ${id.raw})`;
await tx`DELETE
FROM matches
WHERE game_id = ${id.raw}`;
await tx`DELETE
FROM games
WHERE id = ${id.raw}`;
});
return;
}
query: (query: string) => Promise<Game[]> = memo<(query: string) => Promise<Game[]>,Game[]>(this.#query);
async #query(query: string): Promise<Game[]> {
const dbResult: any = await sql` SELECT
id, name
FROM (SELECT *, SIMILARITY(${query}, name) as similarity FROM games)
WHERE similarity > 0
ORDER BY similarity
LIMIT 5;`;
if (!dbResult) {
throw new NotFoundError('No matching game exists');
}
return dbResult.map(
(x: { id: string; name: string }) =>
new Game({
id: SecureId.fromID(x.id),
name: x.name,
}),
);
}
}

View File

@@ -1,16 +1,13 @@
import {ClaimsOrm} from "./claims";
import {UsersOrm} from "./user";
import { ClaimsOrm } from './claims';
import { UsersOrm } from './user';
import { PlayersOrm } from './players';
import { GamesOrm } from './games';
class Orm {
claims: ClaimsOrm;
users: UsersOrm;
constructor() {
this.claims = new ClaimsOrm();
this.users = new UsersOrm(this.claims);
}
readonly claims: ClaimsOrm = new ClaimsOrm();
readonly users: UsersOrm = new UsersOrm();
readonly players: PlayersOrm = new PlayersOrm();
readonly games: GamesOrm = new GamesOrm();
}
export const orm = new Orm();
export const orm = new Orm();

140
src/orm/players.ts Normal file
View File

@@ -0,0 +1,140 @@
import { Claims } from './claims';
import { sql } from 'bun';
import { first } from 'lodash';
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { orm } from './orm';
import { SecureId, UpdatePlayerRequest } from '../utilities/requestModels';
export class Player {
id: SecureId;
name: string;
elo: number;
isRatingLocked: boolean;
canBeMultiple: boolean;
constructor(input: {
id: SecureId;
name: string;
elo?: number;
isRatingLocked?: boolean;
canBeMultiple?: boolean;
}) {
this.id = input.id;
this.name = input?.name;
this.elo = input?.elo ?? 1000;
this.isRatingLocked = input?.isRatingLocked ?? false;
this.canBeMultiple = input?.canBeMultiple ?? false;
}
}
export class PlayersOrm {
async create(model: {name: string}, claims?: Claims): Promise<Player | null> {
await sql`INSERT INTO players (name)
VALUES (${model.name})`;
const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
try {
return await this.get(SecureId.fromID(newPlayerId), claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async get(id: SecureId, claims?: Claims): Promise<Player> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
}
const dbResult: any = first(
await sql`SELECT *
FROM players
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!dbResult) {
throw new NotFoundError('No matching player exists');
}
return new Player({
id: SecureId.fromID(dbResult.id),
name: dbResult.name,
elo: dbResult.elo,
isRatingLocked: dbResult.is_rating_locked,
canBeMultiple: dbResult.can_be_multiple,
});
}
async update(
id: SecureId,
patch: {
name?: string;
isRatingLocked?: boolean;
canBeMultiple?: boolean;
},
claims?: Claims,
): Promise<Player | null> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
}
const playerToUpdate = await this.get(id);
playerToUpdate.name = patch.name ?? playerToUpdate.name;
playerToUpdate.isRatingLocked = patch.isRatingLocked ?? playerToUpdate.isRatingLocked;
playerToUpdate.canBeMultiple = patch.canBeMultiple ?? playerToUpdate.canBeMultiple;
await sql`UPDATE players
SET name=${playerToUpdate.name},
is_rating_locked=${playerToUpdate.isRatingLocked},
can_be_multiple=${playerToUpdate.canBeMultiple}
WHERE id = ${id.raw}`;
try {
return await this.get(id, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async drop(id: SecureId, claims?: Claims): Promise<undefined> {
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) {
throw new UnauthorizedError();
} else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) {
const user = await orm.users.get(SecureId.fromHash(claims.userId));
if (id.raw !== user.playerId.raw) {
throw new UnauthorizedError();
}
}
// Ensure player exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {
await tx`DELETE
FROM player_circles
WHERE player_id = ${id.raw}`;
await tx`DELETE
FROM match_players
WHERE player_id = ${id.raw}`;
await tx`DELETE
FROM players
WHERE id = ${id.raw}`;
});
return;
}
}

View File

@@ -1,17 +1,21 @@
import {Claims, ClaimsOrm} from "./claims";
import {sql} from "bun";
import {first} from "lodash";
import argon2 from "argon2";
import {BadRequestError, NotFoundError, UnauthorizedError} from "../utilities/errors";
import { Claims } from './claims';
import { sql } from 'bun';
import { first } from 'lodash';
import argon2 from 'argon2';
import { BadRequestError, NotFoundError, UnauthorizedError } from '../utilities/errors';
import { SecureId, UpdateUserRequest } from '../utilities/requestModels';
import { orm } from './orm';
export class User {
id: string;
id: SecureId;
playerId: SecureId;
name: string;
isAdmin: boolean;
isActive: boolean;
constructor(id: string, name: string, isAdmin: boolean = false, isActive: boolean = true) {
constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) {
this.id = id;
this.playerId = playerId;
this.name = name;
this.isAdmin = isAdmin;
this.isActive = isActive;
@@ -19,79 +23,200 @@ export class User {
}
export class UsersOrm {
#claims: ClaimsOrm;
constructor(claims: ClaimsOrm) {
this.#claims = claims;
}
async get(id: string, claims: Claims): Promise<User> {
if (!(
Claims.test(claims, 'ADMIN') ||
Claims.test(claims, 'USERS_OTHER_READ') ||
(Claims.test(claims, 'USERS_SELF_READ') && id === claims.userId)
)) {
throw new
UnauthorizedError();
async create(
model: { username: string; password: string; playerId: SecureId },
claims?: Claims,
): Promise<User | null> {
const existingUser: any = first(
await sql`SELECT id
FROM users
WHERE username = ${model.username}
LIMIT 1`,
);
if (existingUser) {
throw new BadRequestError(`User ${model.username} already exists`);
}
const dbResult: any = first(await sql`select *
from users
where id = ${id}
and is_active = true
limit 1`);
const defaultClaims: number[] = await orm.claims.getDefaultClaims();
const passwordHash = await argon2.hash(model.password);
await sql`INSERT INTO users (username, pass_hash, player_id)
VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`;
const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string);
await sql.transaction(async (tx) => {
for (let i in defaultClaims) {
await tx`INSERT INTO user_claims (user_id, claim_id)
VALUES (${newUserId.raw}, ${defaultClaims[i]})`;
}
});
if(!dbResult) {
throw new NotFoundError('No matching user exists');
}
return new User(dbResult.id, dbResult.username, dbResult.is_admin);
}
async verify(username: string, password: string): Promise<Claims | null> {
try {
const dbResult: any = first(await sql`select *
from users
where username = ${username}
limit 1`);
if (!await argon2.verify(dbResult.pass_hash, password)) {
return await this.get(newUserId, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
return this.#claims.getByUserId(dbResult.id);
} catch (error) {
throw error;
}
}
async create(username: string, password: string, claims: Claims): Promise<User | null> {
const existingUser: any = first(await sql`SELECT id
FROM users
WHERE username = ${username}
LIMIT 1`);
if (existingUser) {
throw new BadRequestError(`User ${username} already exists`);
async get(id: SecureId, claims?: Claims): Promise<User> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.READ, claims) ||
(Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId)
)
) {
throw new UnauthorizedError();
}
const defaultClaims: number[] = await this.#claims.getDefaultClaims();
const passwordHash = await argon2.hash(password);
await sql`INSERT INTO users (username, pass_hash)
VALUES (${username}, ${passwordHash})`;
const newUserId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
await sql.transaction(async (tx) => {
for (let i in defaultClaims) {
await tx`INSERT INTO user_claims (userid, claimid)
VALUES (${newUserId}, ${defaultClaims[i]})`;
}
})
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE id = ${id.raw}
AND is_active = true
LIMIT 1`,
);
if (!(
Claims.test(claims, 'ADMIN') ||
Claims.test(claims, 'USERS_OTHER_READ')
)) {
if (!dbResult) {
throw new NotFoundError('No matching user exists');
}
return new User(
SecureId.fromID(dbResult.id),
SecureId.fromID(dbResult.player_id),
dbResult.username,
dbResult.is_admin,
);
}
async update(
id: SecureId,
patch: { isActive?: boolean; isAdmin?: boolean },
claims?: Claims,
): Promise<User | null> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.UPDATE, claims) ||
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId)
)
) {
throw new UnauthorizedError();
}
const userToUpdate = await this.get(id);
if (Claims.test(Claims.ADMIN, claims)) {
userToUpdate.isActive = patch.isActive ?? userToUpdate.isActive;
userToUpdate.isAdmin = patch.isAdmin ?? userToUpdate.isAdmin;
}
await sql`UPDATE users
SET is_active=${userToUpdate.isActive},
is_admin=${userToUpdate.isAdmin}
WHERE id = ${id.raw}`;
try {
return await this.get(id, claims);
} catch (error) {
if (error instanceof UnauthorizedError) {
return null;
}
throw error;
}
}
async drop(id: SecureId, claims?: Claims): Promise<User | null> {
if (
!(
Claims.test(Claims.ADMIN, claims) ||
Claims.test(Claims.USERS.OTHER.DELETE, claims) ||
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId)
)
) {
throw new UnauthorizedError();
}
// Ensure user exists before attempting to delete
await this.get(id);
await sql.transaction(async (tx) => {
await tx`DELETE
FROM user_claims
WHERE user_id = ${id.raw}`;
await tx`DELETE
FROM users
WHERE id = ${id.raw}`;
});
return null;
}
async verifyCredentials(
username: string,
password: string,
): Promise<{ userId: SecureId; refreshCount: string } | null> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE username = ${username}
AND is_active = true
limit 1`,
);
if (!dbResult) {
throw new UnauthorizedError();
}
if (!(await argon2.verify(dbResult.pass_hash, password))) {
return null;
}
return await this.get(newUserId, claims);
return {
userId: SecureId.fromID(dbResult.id),
refreshCount: dbResult.refresh_count,
};
}
}
async verifyRefreshCount(id: SecureId, refreshCount: string): Promise<boolean> {
const dbResult: any = first(
await sql`SELECT *
FROM users
WHERE id = ${id.raw}
LIMIT 1`,
);
console.log(dbResult.refresh_count, refreshCount);
return dbResult.refresh_count === refreshCount;
}
async changePassword(
id: SecureId,
oldPassword: string | null,
newPassword: string,
claims?: Claims,
): Promise<undefined> {
const isAdmin = Claims.test(Claims.ADMIN, claims);
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) {
throw new UnauthorizedError();
}
if (!isAdmin && oldPassword === null) {
throw new BadRequestError('Password is required');
}
const dbUser: any = first(
await sql`SELECT *
FROM users
WHERE id = ${id.raw}
LIMIT 1`,
);
if (!isAdmin && !(await argon2.verify(dbUser.pass_hash, oldPassword as string))) {
throw new UnauthorizedError();
}
const passwordHash = await argon2.hash(newPassword);
await sql`UPDATE users
SET pass_hash=${passwordHash}
WHERE id = ${id.raw}`;
return;
}
}

22
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,22 @@
import { guard, unwrapMethod } from '../utilities/guard';
import auth from '../endpoints/auth';
import { OkResponse } from '../utilities/responseHelper';
import { Claims } from '../orm/claims';
export default {
'/api/auth/login': {
POST: unwrapMethod(auth.login),
},
'/api/auth/token': {
GET: unwrapMethod(auth.token),
},
'/api/auth/logout': {
POST: unwrapMethod(auth.logout),
},
'/api/auth/changePassword/:id': {
PATCH: guard(auth.changePassword, [Claims.ADMIN, Claims.USERS.SELF.UPDATE]),
},
'/api/auth/test': {
GET: () => new OkResponse(),
},
};

17
src/routes/game.ts Normal file
View File

@@ -0,0 +1,17 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import games from '../endpoints/games';
export default {
'/api/game': {
POST: guard(games.create, [Claims.ADMIN, Claims.GAMES.CREATE]),
},
'/api/game/:id': {
GET: guard(games.get, [Claims.ADMIN, Claims.GAMES.READ]),
PATCH: guard(games.update, [Claims.ADMIN, Claims.GAMES.UPDATE]),
DELETE: guard(games.drop, [Claims.ADMIN, Claims.GAMES.DELETE]),
},
'/api/game/search/:query': {
GET: guard(games.query, [Claims.ADMIN, Claims.GAMES.READ]),
},
};

14
src/routes/player.ts Normal file
View File

@@ -0,0 +1,14 @@
import { guard } from '../utilities/guard';
import { Claims } from '../orm/claims';
import player from '../endpoints/player';
export default {
'/api/player': {
POST: guard(player.create, [Claims.ADMIN, Claims.PLAYERS.CREATE]),
},
'/api/player/:id': {
GET: guard(player.get, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ, Claims.PLAYERS.SELF.READ]),
PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]),
DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]),
},
};

14
src/routes/user.ts Normal file
View File

@@ -0,0 +1,14 @@
import { guard } from '../utilities/guard';
import user from '../endpoints/user';
import { Claims } from '../orm/claims';
export default {
'/api/user': {
POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]),
},
'/api/user/:id': {
GET: guard(user.get, [Claims.ADMIN, Claims.USERS.OTHER.READ, Claims.USERS.SELF.READ]),
PATCH: guard(user.update, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]),
DELETE: guard(user.drop, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]),
},
};

119
src/tests/auth.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { expect, test } from 'bun:test';
import auth from '../endpoints/auth';
import { UnwrappedRequest } from '../utilities/guard';
import { Claims } from '../orm/claims';
import { orm } from '../orm/orm';
import { User } from '../orm/user';
test('login', async () => {
await orm.users.create('authTest', 'test123');
const request = new UnwrappedRequest({
json: {
username: 'authTest',
password: 'test123',
},
});
const response = await auth.login(request);
expect(response.status).toBe(200);
});
test("login user that doesn't exist", async () => {
const request = new UnwrappedRequest({
json: {
username: 'thisUserDoesNotExist',
password: 'test123',
},
});
const response = await auth.login(request);
expect(response.status).toBe(401);
});
test('login with invalid password', async () => {
const createdUser = (await orm.users.create('authTest2', 'test123')) as User;
const request = new UnwrappedRequest({
json: {
username: 'authTest2',
password: 'wrongPassword',
},
});
const response = await auth.login(request);
expect(response.status).toBe(401);
});
test('Change password', async () => {
const claims = new Claims();
claims.claims.push(Claims.USERS.SELF.UPDATE);
const testUser = (await orm.users.create('authTest3', 'test123')) as User;
claims.userId = testUser.id;
const request = new UnwrappedRequest({
claims,
params: {
id: testUser.id,
},
json: {
oldPassword: 'test123',
newPassword: 'test1234',
},
});
const response = await auth.changePassword(request);
expect(response.status).toBe(200);
expect(response.body).toBeNull();
});
test('Change password with incorrect old password', async () => {
const claims = new Claims();
claims.claims.push(Claims.USERS.SELF.UPDATE);
const testUser = (await orm.users.create('authTest4', 'test123')) as User;
claims.userId = testUser.id;
const request = new UnwrappedRequest({
claims,
params: {
id: testUser.id,
},
json: {
oldPassword: 'wrongPassword',
newPassword: 'test1234',
},
});
const response = await auth.changePassword(request);
expect(response.status).toBe(401);
});
test('Change password as admin', async () => {
const claims = new Claims();
claims.userId = '1';
claims.claims.push(Claims.ADMIN);
const testUser = (await orm.users.create('authTest5', 'test123')) as User;
const request = new UnwrappedRequest({
claims,
params: {
id: testUser.id,
},
json: {
newPassword: 'test1234',
},
});
const response = await auth.changePassword(request);
expect(response.status).toBe(200);
expect(response.body).toBeNull();
});

View File

@@ -1,2 +1,2 @@
import {expect, test, beforeAll} from 'bun:test';
import {sql} from "bun";
import { expect, test, beforeAll } from 'bun:test';
import { sql } from 'bun';

View File

@@ -1,6 +1,6 @@
import {beforeAll} from 'bun:test';
import { beforeAll } from 'bun:test';
import Bun from 'bun';
import {sql} from "bun";
import { sql } from 'bun';
beforeAll(async () => {
console.log(process.env.DATABASE_URL);
@@ -24,12 +24,3 @@ beforeAll(async () => {
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_OTHER_READ', true)`;
await sql`INSERT INTO claims(name, is_default) VALUES ('USERS_SELF_DELETE', false)`;
});

View File

@@ -1,11 +1,13 @@
import {expect, test} from 'bun:test';
import { expect, test } from 'bun:test';
import user from '../endpoints/user';
import {UnwrappedRequest} from "../utilities/guard";
import {Claims} from "../orm/claims";
import { UnwrappedRequest } from '../utilities/guard';
import { Claims } from '../orm/claims';
import { orm } from '../orm/orm';
import { User } from '../orm/user';
test('Create user as admin', async () => {
const claims = new Claims();
claims.claims.push('ADMIN');
claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({
claims,
@@ -24,7 +26,7 @@ test('Create user as admin', async () => {
test('Create user without read access', async () => {
const claims = new Claims();
claims.claims.push('USERS_CREATE');
claims.claims.push(Claims.USERS.CREATE);
const request = new UnwrappedRequest({
claims,
@@ -43,7 +45,7 @@ test('Create user without read access', async () => {
test('Create user that already exists', async () => {
const claims = new Claims();
claims.claims.push('USERS_CREATE');
claims.claims.push(Claims.USERS.CREATE);
const request = new UnwrappedRequest({
claims,
@@ -61,13 +63,13 @@ test('Create user that already exists', async () => {
test('Get user', async () => {
const claims = new Claims();
claims.claims.push('USERS_OTHER_READ');
claims.claims.push(Claims.USERS.OTHER.READ);
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
id: 1,
},
});
@@ -79,14 +81,14 @@ test('Get user', async () => {
test('Get user self with only self read permission', async () => {
const claims = new Claims();
claims.userId = "1";
claims.claims.push('USERS_OTHER_READ');
claims.userId = '1';
claims.claims.push(Claims.USERS.OTHER.READ);
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
id: 1,
},
});
@@ -98,14 +100,14 @@ test('Get user self with only self read permission', async () => {
test('Get other user without read permissions', async () => {
const claims = new Claims();
claims.userId = "2";
claims.claims.push('USERS_SELF_READ');
claims.userId = '2';
claims.claims.push(Claims.USERS.SELF.READ);
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 1
id: 1,
},
});
@@ -113,18 +115,170 @@ test('Get other user without read permissions', async () => {
expect(response.status).toBe(401);
});
test('Get user that doesn\'t exist', async () => {
test("Get user that doesn't exist", async () => {
const claims = new Claims();
claims.claims.push('ADMIN');
claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: 101
id: 101,
},
});
const response = await user.get(request);
expect(response.status).toBe(404);
});
});
test('Update user', async () => {
const claims = new Claims();
claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({
claims,
request: null,
json: {
isAdmin: true,
},
params: {
id: 2,
},
});
const response = await user.update(request);
expect(response.status).toBe(200);
expect(response.body).toBeDefined();
});
test('Update user without read access', async () => {
const claims = new Claims();
claims.userId = '1';
claims.claims.push(Claims.USERS.OTHER.UPDATE);
const request = new UnwrappedRequest({
claims,
request: null,
json: {
isAdmin: true,
},
params: {
id: 2,
},
});
const response = await user.update(request);
expect(response.status).toBe(200);
expect(response.body).toBeNull();
});
test('Update user without permissions', async () => {
const claims = new Claims();
claims.userId = '1';
const request = new UnwrappedRequest({
claims,
request: null,
json: {
isAdmin: true,
},
params: {
id: 2,
},
});
const response = await user.update(request);
expect(response.status).toBe(401);
});
test("Update user that doesn't exist", async () => {
const claims = new Claims();
claims.userId = '1';
claims.claims.push(Claims.ADMIN);
const request = new UnwrappedRequest({
claims,
request: null,
json: {
isAdmin: true,
},
params: {
id: 101,
},
});
const response = await user.update(request);
expect(response.status).toBe(404);
});
test('Delete user', async () => {
const claims = new Claims();
claims.claims.push(Claims.ADMIN);
const createdUser = (await orm.users.create('test3', 'test123')) as User;
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: createdUser.id,
},
});
const response = await user.drop(request);
expect(response.status).toBe(200);
});
test('Delete user without delete permissions', async () => {
const claims = new Claims();
const createdUser = (await orm.users.create('test4', 'test123')) as User;
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: createdUser.id,
},
});
const response = await user.drop(request);
expect(response.status).toBe(401);
});
test('Delete self user with only self delete permissions', async () => {
const claims = new Claims();
claims.claims.push(Claims.USERS.SELF.DELETE);
const createdUser = (await orm.users.create('test5', 'test123')) as User;
claims.userId = createdUser.id;
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: createdUser.id,
},
});
const response = await user.drop(request);
expect(response.status).toBe(200);
});
test('Delete other user with only self delete permissions', async () => {
const claims = new Claims();
claims.userId = '1';
claims.claims.push(Claims.USERS.SELF.DELETE);
const createdUser = (await orm.users.create('test6', 'test123')) as User;
const request = new UnwrappedRequest({
claims,
request: null,
params: {
id: createdUser.id,
},
});
const response = await user.drop(request);
expect(response.status).toBe(401);
});

View File

@@ -0,0 +1,131 @@
export class ClaimDefinition {
public static readonly ADMIN = 'ADMIN';
public static readonly USERS = {
CREATE: 'USERS_CREATE',
SELF: {
READ: 'USERS_SELF_READ',
UPDATE: 'USERS_SELF_UPDATE',
DELETE: 'USERS_SELF_DELETE',
},
OTHER: {
READ: 'USERS_OTHER_READ',
UPDATE: 'USERS_OTHER_UPDATE',
DELETE: 'USERS_OTHER_DELETE',
},
};
public static readonly PLAYERS = {
CREATE: 'PLAYERS_CREATE',
SELF: {
READ: 'PLAYERS_SELF_READ',
UPDATE: 'PLAYERS_SELF_UPDATE',
DELETE: 'PLAYERS_SELF_DELETE',
},
OTHER: {
READ: 'PLAYERS_OTHER_READ',
UPDATE: 'PLAYERS_OTHER_UPDATE',
DELETE: 'PLAYERS_OTHER_DELETE',
},
};
public static readonly CIRCLES = {
PUBLIC: {
CREATE: 'CIRCLES_PUBLIC_CREATE',
JOIN: 'CIRCLES_PUBLIC_JOIN',
USERS: {
ADD: 'CIRCLES_PUBLIC_USER_ADD',
LIST: 'CIRCLES_PUBLIC_USER_LIST',
INVITE: 'CIRCLES_PUBLIC_USER_INVITE',
},
COMMENTS: {
ADD: 'CIRCLES_PUBLIC_COMMENTS_ADD',
DELETE: 'CIRCLES_PUBLIC_COMMENTS_DELETE',
},
},
PRIVATE: {
CREATE: 'CIRCLES_PRIVATE_CREATE',
USERS: {
INVITE: 'CIRCLES_PRIVATE_USER_INVITE',
},
},
OWNED: {
READ: 'CIRCLES_OWNED_READ',
UPDATE: 'CIRCLES_OWNED_UPDATE',
DELETE: 'CIRCLES_OWNED_DELETE',
USERS: {
ADD: 'CIRCLES_OWNED_USER_ADD',
LIST: 'CIRCLES_OWNED_USER_LIST',
USERS: {
INVITE: 'CIRCLES_OWNED_USER_INVITE',
},
},
COMMENTS: {
ADD: 'CIRCLES_OWNED_COMMENTS_ADD',
DELETE: 'CIRCLES_OWNED_COMMENTS_DELETE',
},
},
UNOWNED: {
READ: 'CIRCLES_UNOWNED_READ',
UPDATE: 'CIRCLES_UNOWNED_UPDATE',
DELETE: 'CIRCLES_UNOWNED_DELETE',
COMMENTS: {
ADD: 'CIRCLES_UNOWNED_COMMENTS_ADD',
DELETE: 'CIRCLES_UNOWNED_COMMENTS_DELETE',
},
},
};
public static readonly GAMES = {
CREATE: 'GAMES_CREATE',
READ: 'GAMES_READ',
UPDATE: 'GAMES_UPDATE',
DELETE: 'GAMES_DELETE',
MANAGE_IMAGES: 'GAMES_IMAGES_MANAGE',
};
public static readonly MATCHES = {
CREATE: 'MATCHES_CREATE',
OWNED: {
READ: 'MATCHES_OWNED_READ',
UPDATE: 'MATCHES_OWNED_UPDATE',
DELETE: 'MATCHES_OWNED_DELETE',
COMMENTS: {
ADD: 'MATCHES_OWNED_COMMENTS_ADD',
DELETE: 'MATCHES_OWNED_COMMENTS_DELETE',
},
},
UNOWNED: {
READ: 'MATCHES_UNOWNED_READ',
UPDATE: 'MATCHES_UNOWNED_UPDATE',
DELETE: 'MATCHES_UNOWNED_DELETE',
COMMENTS: {
ADD: 'MATCHES_UNOWNED_COMMENTS_ADD',
DELETE: 'MATCHES_UNOWNED_COMMENTS_DELETE',
},
},
};
public static readonly COLLECTIONS = {
CREATE: 'COLLECTIONS_CREATE',
OWNED: {
READ: 'COLLECTIONS_OWNED_READ',
UPDATE: 'COLLECTIONS_OWNED_UPDATE',
DELETE: 'COLLECTIONS_OWNED_DELETE',
COMMENTS: {
DELETE: 'COLLECTIONS_OWNED_COMMENTS_DELETE',
},
},
UNOWNED: {
READ: 'COLLECTIONS_UNOWNED_READ',
UPDATE: 'COLLECTIONS_UNOWNED_UPDATE',
DELETE: 'COLLECTIONS_UNOWNED_DELETE',
},
};
public static readonly COMMENTS = {
OWNED: {
READ: 'COMMENTS_OWNED_READ',
UPDATE: 'COMMENTS_OWNED_UPDATE',
DELETE: 'COMMENTS_OWNED_DELETE',
},
UNOWNED: {
READ: 'COMMENTS_UNOWNED_READ',
UPDATE: 'COMMENTS_UNOWNED_UPDATE',
DELETE: 'COMMENTS_UNOWNED_DELETE',
},
};
}

45
src/utilities/elo.ts Normal file
View File

@@ -0,0 +1,45 @@
import { orderBy } from 'lodash';
interface GamePlayer {
id: string;
elo: number;
gamesPlayed: number;
standing: number;
eloChange?: number;
}
export interface GamePlayed {
players: GamePlayer[];
}
export function calculateElos(game: GamePlayed, provisionalPeriod: number = 1): GamePlayed {
const orderedResults = orderBy(game.players, 'standing', 'asc');
for (let i = 0; i < orderedResults.length - 1; i++) {
for (let j = i + 1; j < orderedResults.length; j++) {
const challengerResults = calculateEloChange(orderedResults[i].elo, orderedResults[j].elo);
orderedResults[i].eloChange =
(orderedResults[i].eloChange ?? 0) +
challengerResults.winnerChange * Math.min(1, orderedResults[j].gamesPlayed / provisionalPeriod);
orderedResults[j].eloChange =
(orderedResults[j].eloChange ?? 0) +
challengerResults.loserChange * Math.min(1, orderedResults[i].gamesPlayed / provisionalPeriod);
}
}
return {
players: orderedResults,
};
}
interface EloResult {
winnerChange: number;
loserChange: number;
}
function calculateEloChange(winnerElo: number, loserElo: number, draw: boolean = false): EloResult {
const ratingStep = 32;
const expectedWinnerResult = 1 / (1 + Math.pow(10, (loserElo - winnerElo) / 400));
const expectedLoserResult = 1 / (1 + Math.pow(10, (winnerElo - loserElo) / 400));
return {
winnerChange: ratingStep * ((draw ? 0.5 : 1) - expectedWinnerResult),
loserChange: ratingStep * ((draw ? 0.5 : 0) - expectedLoserResult),
};
}

View File

@@ -1,17 +1,17 @@
export class BadRequestError extends Error {
constructor(message: string | undefined = undefined) {
constructor(message?: string | undefined) {
super(message);
}
}
export class UnauthorizedError extends Error {
constructor(message: string | undefined = undefined) {
constructor(message?: string | undefined) {
super(message);
}
}
export class NotFoundError extends Error {
constructor(message: string | undefined = undefined) {
constructor(message?: string | undefined) {
super(message);
}
}
}

View File

@@ -1,10 +1,17 @@
import {BunRequest as Request} from 'bun';
import jwt from 'jsonwebtoken';
import {ErrorResponse} from "./responseHelper";
import {UnauthorizedError} from "./errors";
import {Claims} from "../orm/claims";
import { BunRequest as Request } from 'bun';
import jwt, { TokenExpiredError } from 'jsonwebtoken';
import { ErrorResponse, UnauthorizedResponse } from './responseHelper';
import { UnauthorizedError } from './errors';
import { Claims } from '../orm/claims';
import HashIds from 'hashids';
export function guardRedirect(method: Function, redirectMethod: Function, guardedClaims: string[] | undefined = undefined) {
export const hashIds = new HashIds(process.env.JWT_SECRET, 4);
export function guardRedirect(
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
redirectMethod: Function,
guardedClaims: string[],
) {
try {
return guard(method, guardedClaims);
} catch (e) {
@@ -12,47 +19,56 @@ export function guardRedirect(method: Function, redirectMethod: Function, guarde
}
}
export function guard(method: Function, guardedClaims: string[] | undefined = undefined): (r: Request) => Promise<Response> {
export function guard(
method: (request: UnwrappedRequest<any>) => Promise<Response> | Response,
guardedClaims: string[],
): (r: Request) => Promise<Response> {
return async (request: Request): Promise<Response> => {
const authHeader: string | null = request.headers.get('Authorization')?.replace(/^Bearer /, '') as string ?? null;
const authHeader: string | null =
(request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null;
try {
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims;
if (guardedClaims !== undefined && !userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
throw new UnauthorizedError('Unauthorized');
}
return method(await unwrap(request, userClaims));
} catch (error: any) {
if (error instanceof TokenExpiredError) {
return new UnauthorizedResponse(error.message);
}
return new ErrorResponse(error as Error);
}
}
};
}
export class UnwrappedRequest {
readonly json: any;
export class UnwrappedRequest<T> {
readonly body: T;
readonly request: Request;
readonly params: { [x: string]: string };
readonly claims: Claims;
constructor(input: any) {
this.json = input.json;
this.body = input.body;
this.request = input.request;
this.claims = input.claims;
this.claims = input.claims || new Claims();
this.params = input.params;
}
}
export async function unwrap(request: Request, claims: Claims | null = null) {
return new UnwrappedRequest({
export async function unwrap<T>(request: Request, claims?: Claims) {
return new UnwrappedRequest<T>({
request,
claims,
json: request.body ? await request.json() : null,
body: request.body ? await request.json() : null,
params: request.params,
})
});
}
export function unwrapMethod(methodToUnwrap: ((r: UnwrappedRequest) => Response) | ((r: UnwrappedRequest) => Promise<Response>)): (r: Request) => Promise<Response> {
export function unwrapMethod<T>(
methodToUnwrap: ((r: UnwrappedRequest<T>) => Response) | ((r: UnwrappedRequest<T>) => Promise<Response>),
): (r: Request) => Promise<Response> {
return async (request: Request) => {
const unwrappedRequest = await unwrap(request);
const unwrappedRequest = await unwrap<T>(request);
return await methodToUnwrap(unwrappedRequest);
};
}
}

20
src/utilities/helpers.ts Normal file
View File

@@ -0,0 +1,20 @@
export function memo<T extends (...args: any[]) => {}, S>(
func: T,
lifespan: number = 5 * 60 * 1000,
keyDelegate?: (...args: any[]) => string,
): T {
const cache: { [key: string]: { value: S; timestamp: number } } = {};
return ((...args: any[]): S => {
const key: string = (keyDelegate ? keyDelegate(...args) : args?.[0]?.toString()) ?? '';
const now = Date.now();
if (!cache[key] || now - cache[key].timestamp > lifespan) {
cache[key] = {
value: func(...args) as S,
timestamp: now,
};
}
return cache[key].value;
}) as unknown as T;
}

View File

@@ -0,0 +1,75 @@
import { hashIds } from './guard';
export interface LoginRequest {
username: string;
password: string;
}
export interface ChangePasswordRequest {
oldPassword: string | null;
newPassword: string;
}
export interface CreateUserRequest {
username: string;
password: string;
playerId: string;
}
export interface UpdateUserRequest {
isActive?: boolean;
isAdmin?: boolean;
}
export interface CreatePlayerRequest {
name: string;
}
export interface UpdatePlayerRequest {
name?: string;
isRatingLocked?:boolean;
canBeMultiple?:boolean;
}
export interface CreateGameRequest {
name: string;
imagePath?: string;
bggId?: string;
}
export interface UpdateGameRequest {
name: string;
imagePath?: string;
bggId?: string;
}
export class SecureId {
#hashedValue?: string;
#secureValue?: string;
get value(): string | undefined {
return this.#hashedValue;
}
set value(value: string) {
this.#hashedValue = value;
this.#secureValue = hashIds.decode(value)?.toString();
}
get raw(): string | undefined {
return this.#secureValue;
}
set raw(value: string) {
this.#hashedValue = hashIds.encode(value);
this.#secureValue = value;
}
constructor(id: { public?: string; secure?: string }) {
if (id.public) {
this.value = id.public;
} else if (id.secure) {
this.raw = id.secure;
}
}
toJSON(): string | undefined {
return this.#hashedValue;
}
public static fromHash(hash: string) {
return new SecureId({ public: hash });
}
public static fromID(id: string) {
return new SecureId({ secure: id });
}
}

View File

@@ -1,18 +1,75 @@
import {BadRequestError, NotFoundError, UnauthorizedError} from "./errors";
import { BadRequestError, NotFoundError, UnauthorizedError } from './errors';
import { hashIds } from './guard';
export class ErrorResponse extends Response {
//@ts-ignore
constructor(error: Error) {
if(error instanceof BadRequestError) {
return Response.json({message: error.message}, {status: 400});
}
else if(error instanceof UnauthorizedError){
return Response.json({message: error.message}, {status: 401});
}
else if(error instanceof NotFoundError){
return Response.json({message: error.message}, {status: 404});
if (error instanceof BadRequestError) {
return new BadRequestResponse(error.message);
} else if (error instanceof UnauthorizedError) {
return new UnauthorizedResponse(error.message);
} else if (error instanceof NotFoundError) {
return new NotFoundResponse(error.message);
}
return Response.json({message: error.message}, {status: 500});
return Response.json({ message: error.message }, { status: 500 });
}
}
}
export class BadRequestResponse extends Response {
// @ts-ignore
constructor(message?: string) {
return Response.json({ message: message }, { status: 400 });
}
}
export class UnauthorizedResponse extends Response {
// @ts-ignore
constructor(message?: string) {
return Response.json({ message: message }, { status: 401 });
}
}
export class NotFoundResponse extends Response {
// @ts-ignore
constructor(message?: string) {
return Response.json({ message: message }, { status: 404 });
}
}
export class OkResponse extends Response {
// @ts-ignore
constructor(body?: Model | null) {
if (body) {
return Response.json(
{
...body,
},
{
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
);
}
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
},
});
}
}
export class CreatedResponse extends Response {
// @ts-ignore
constructor(body?: any) {
if (body) {
return Response.json({ ...body }, { status: 201 });
}
return new Response(null, { status: 201 });
}
}