untested WIP

This commit is contained in:
2026-01-24 16:59:50 -06:00
parent 935c7296f6
commit 4be36e6ca0
56 changed files with 8645 additions and 3 deletions
+8
View File
@@ -0,0 +1,8 @@
export default class AuthenticationError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "AuthenticationError";
this.message = message;
this.cause = cause;
}
}
+11
View File
@@ -0,0 +1,11 @@
export default class AuthorizationError extends Error {
public status: number;
constructor(message: string, cause?: string, status?: number) {
super();
this.name = "AuthorizationError";
this.status = status ?? 401;
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class BodyError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "BodyError";
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class ExpiredAccessTokenError extends Error {
constructor(cause?: string) {
super();
this.name = "ExpiredAccessTokenError";
this.message = "The provided access token has expired.";
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class ExpiredRefreshTokenError extends Error {
constructor(cause?: string) {
super();
this.name = "ExpiredRefreshTokenError";
this.message = "The provided refresh token has expired.";
this.cause = cause;
}
}
+16
View File
@@ -0,0 +1,16 @@
export default class GenericError extends Error {
public status: number;
constructor(info: {
name: string;
message: string;
cause?: string;
status?: number;
}) {
super();
this.name = info.name;
this.status = info.status ?? 400;
this.message = info.message;
this.cause = info.cause;
}
}
+11
View File
@@ -0,0 +1,11 @@
export default class InsufficientPermission extends Error {
public status: number;
constructor(message: string, cause?: string) {
super();
this.name = "InsufficientPermission";
this.status = 403;
this.message = message;
this.cause = cause;
}
}
+9
View File
@@ -0,0 +1,9 @@
export default class MissingBodyValue extends Error {
constructor(valueName: string) {
super();
this.name = "MissingBodyValue";
this.message = `Value '${valueName}' is missing from the body.`;
this.cause =
"A value that was required by the body of this request is missing.";
}
}
@@ -0,0 +1,8 @@
export default class PermissionsVerificationError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "PermissionsVerificationError";
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class RoleError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "RoleError";
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class SessionError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "SessionError";
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class SessionTokenError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "SessionTokenError";
this.message = message;
this.cause = cause;
}
}
+8
View File
@@ -0,0 +1,8 @@
export default class UserError extends Error {
constructor(message: string, cause?: string) {
super();
this.name = "UserError";
this.message = message;
this.cause = cause;
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+12
View File
@@ -0,0 +1,12 @@
// This file was generated by Prisma, and assumes you run Prisma commands using `bun --bun run prisma [command]`.
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: env("DATABASE_URL"),
},
});
+53
View File
@@ -0,0 +1,53 @@
import { Hono } from "hono";
import { apiResponse } from "../modules/api-utils/apiResponse";
import { ZodError } from "zod";
import { cors } from "hono/cors";
import GenericError from "../Errors/GenericError";
import teapot from "./teapot";
const app = new Hono();
const v1 = new Hono();
app.onError((err, ctx) => {
const errClassName = err.constructor.name;
if (
errClassName.toLowerCase().includes("prisma") ||
err.message.toLowerCase().includes("prisma") ||
err.name.toLowerCase().includes("prisma")
) {
console.trace(err);
return ctx.json(apiResponse.internalError(), 500);
}
if (err instanceof ZodError) {
return ctx.json(
apiResponse.zodError(err),
//@ts-ignore
apiResponse.zodError(err).status
);
}
const response = apiResponse.error(err);
return ctx.json(response, response.status);
});
app.use("*", cors());
app.notFound((c) => {
const response = apiResponse.error(
new GenericError({
name: "NotFound",
message: `Cannot ${c.req.method.toUpperCase()} ${c.req.path}`,
status: 404,
cause: "Unknown",
})
);
return c.json(response, response.status);
});
v1.route("/teapot", teapot);
app.route("/v1", v1);
export default app;
+12
View File
@@ -0,0 +1,12 @@
import { Hono } from "hono/tiny";
import { createRoute } from "../modules/api-utils/createRoute";
/* /v1/teapot */
export default createRoute("get", ["/"], (c) => {
c.status(418);
return c.json({
status: 418,
message: "I'm not a teapot",
successful: true,
});
});
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync } from "fs";
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '../generated/prisma/client'
const connectionString = `${process.env.DATABASE_URL}`
const adapter = new PrismaPg({ connectionString })
interface EnvKey {
PORT: number;
};
// ENV CONSTANTS
export const PORT = process.env.PORT;
export const prisma = new PrismaClient({ adapter })
export const sessionDuration = 30 * 24 * 60 * 60000;
export const accessTokenDuration = "10min";
export const refreshTokenDuration = "30d";
export const accessTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.accessToken.key`
).toString();
export const refreshTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.refreshToken.key`
).toString();
export const permissionsPrivateKey = readFileSync(
`${import.meta.dir}/../.permissions.key`
);
export const apiKeyTokenPrivateKey = readFileSync(
`${import.meta.dir}/../.apiKeyToken.key`
);
+338
View File
@@ -0,0 +1,338 @@
import UserController from "./UserController";
import { Collection } from "@discordjs/collection";
import jwt, { JsonWebTokenError } from "jsonwebtoken";
import { permissionsPrivateKey, prisma } from "../constants";
import PermissionsVerificationError from "../Errors/PermissionsVerificationError";
import { mergeArrays } from "../modules/tools/mergeArrays";
import { signPermissions } from "../modules/permission-utils/signPermissions";
import { DecodedPermissionsBlock } from "../types/PermissionTypes";
import { permissionValidator } from "../modules/permission-utils/permissionValidator";
import { z } from "zod";
import { roles } from "../managers/roles";
import GenericError from "../Errors/GenericError";
import RoleError from "../Errors/RoleError";
import { Role, User } from "../../generated/prisma/client";
import { events } from "../modules/globalEvents";
/**
* Roles
*
* Roles are for adding onto a users permissions. They are not for defining a default users permissions.
*
* Roles have two forms of identifiers, titles and monikers. The title is something that can be capitalized and publicly displayed,
* so if you make modifications, and you need to be able to see how they were able to do that without having explicit permission to
* make the modifications, the title is what would be shown to you. The moniker is the human readable identifier. So in an api
* response where you need to get the roles of a user, instead of being given a bunch of id's, you will be given a bunch of monikers
* which are easy to read and easy to identify.
*/
export class RoleController {
public readonly id: string;
public title: string;
public moniker: string; // e.g. admin, super_admin, moderator
private _permissionsToken: string;
private _users: (User & { roles: Role[] })[];
public readonly createdAt: Date;
public updatedAt: Date;
public deleted: boolean = false;
constructor(roledata: Role & { users: (User & { roles: Role[] })[] }) {
this.id = roledata.id;
this.title = roledata.title;
this.moniker = roledata.moniker;
this._permissionsToken = roledata.permissions;
this._users = roledata.users;
this.createdAt = roledata.createdAt;
this.updatedAt = roledata.updatedAt;
}
private _signPermissions = signPermissions;
/**
* Verify Permissions
*
* This method is vital to maintining the security of this system. This method is here to ensure
* that the permissions object is authentic and signed by our system.
*
* @param permissionsToken - Signed permissions JWT
* @returns - Verified object with permissions in it.
*/
private _verifyPermissions(permissionsToken: string) {
let perms: DecodedPermissionsBlock;
try {
perms = jwt.verify(permissionsToken, permissionsPrivateKey, {
algorithms: ["RS256"],
issuer: "roles",
subject: this.id,
}) as DecodedPermissionsBlock;
} catch (err) {
events.emit("role:permissions:verification_error", {
currentSigned: this._permissionsToken,
attemptedVerification: permissionsToken,
err: err as Error,
role: this,
});
throw new PermissionsVerificationError(
`Unable to verify permissions for role '${this.title}, it is recommended that you override and rewrite these permissions immediately.`,
(err as Error).message
);
}
return perms as { permissions: string[] };
}
/**
* Get Users
*
* This will get all the users that have this role and return it as a collection dictionary where the key is
* the `id` of the user and the value is the users `UserController`.
*
* @returns {Collection<string, UserController>} - A collection of all the users that are assigned to this role
*/
public getUsers() {
const collection = new Collection<string, UserController>();
this._users.map((v) => collection.set(v.id, new UserController(v)));
return collection;
}
/**
* Check Permission
*
* Check to see if a role has a specified set of permissions.
*
* @param permission - The permission to check for
* @returns {boolean} Does this role have the specified permission
*/
public checkPermission(permission: string): boolean {
const permissions = this._verifyPermissions(this._permissionsToken);
return permissionValidator(permission, permissions.permissions);
}
/**
* Set permissions
*
* This will remove all existing permissions and replate them with the permissions defined in the params
* of this method.
*
* @param permissions - Array of all Permissions
* @returns {void}
*/
public async setPermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions,
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "set",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Add Permissions
*
* This will take the permissions provided in the params of the method and combine them into the pre-existing
* permissions of this role.
*
* @param permissions - Array of added Permissions
* @returns {void}
*/
public async addPermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions: mergeArrays(previous.permissions, permissions),
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "added",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Remove Permissions
*
* This will take the permissions provided in the params of the method and remove them from the exisitng
* permissions object for this role.
*
* @param permissions - Array of removed Permissions
* @returns {void}
*/
public async removePermissions(...permissions: string[]) {
/*
Make sure the current permissions are verified before updating them again. Basically speaking if the permissions
are tampered with in any way, this bricks the further modification of the permissions
*/
const previous = this._verifyPermissions(this._permissionsToken);
const newPermissionsToken = this._signPermissions({
issuer: "roles",
subject: this.id,
permissions: previous.permissions.filter((v) => !permissions.includes(v)),
});
const newRaw = await prisma.role.update({
where: { id: this.id },
data: { permissions: newPermissionsToken },
});
events.emit("role:permissions:updated", {
current: permissions,
currentSigned: newPermissionsToken,
previous: previous.permissions,
previousSigned: this._permissionsToken,
action: "removed",
role: this,
});
this._permissionsToken = newPermissionsToken;
this.updatedAt = newRaw.updatedAt;
return;
}
/**
* Get Current Permissions
*
* Get all of the permissions for this role.
* @returns {string[]} - Existing permissions in an array
*/
public getPermissions() {
const permissions = this._verifyPermissions(this._permissionsToken!);
return permissions;
}
public async update(
data: Partial<{
title: string;
moniker: string;
permissions: string[];
}>
) {
const schema = z
.object({
title: z.string().min(1, "Title cannot be empty."),
moniker: z.string().min(1, "Moniker cannot be empty."),
permissions: z.array(
z.string().min(1, "Permission node cannot be empty")
),
})
.partial()
.strict();
data = schema.parse(data);
if (data.moniker) {
const checkMoniker = await prisma.role.findFirst({
where: { moniker: data.moniker },
});
if (checkMoniker)
throw new RoleError(
"Moniker is already taken.",
"Another role with this moniker already exists in the databse."
);
}
const updatedRole = await prisma.role.update({
where: { id: this.id },
data: {
...data,
permissions: undefined,
},
});
if (data.permissions) await this.setPermissions(...data.permissions);
events.emit("role:updated", { role: this, updateData: data });
this.title = data.title ?? this.title;
this.moniker = data.moniker ?? this.moniker;
return this;
}
/**
* Delete Role
*
* @returns {Promise<RoleController>} The Remains of a deleted role.
*/
public async delete() {
const deletedData = await prisma.role.delete({ where: { id: this.id } });
this.deleted = true;
events.emit("role:deleted", this);
return this;
}
/**
* To JSON
*
* Create a JSON object that can be used in things like API responses.
*
* @param opts - Optional values to include in the response.
* @returns - JSON-Friendly object.
*/
public toJson(opts?: { viewUsers?: boolean; viewPermissions?: boolean }) {
let object = {
id: this.id,
title: this.title,
moniker: this.moniker,
permissions: opts?.viewPermissions
? this._verifyPermissions(this._permissionsToken).permissions
: undefined,
users: opts?.viewUsers
? this._users.map((v) => ({
id: v.id,
name: v.name,
login: v.login,
roles: v.roles.map((r:any) => r.id),
}))
: undefined,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
};
return object;
}
}
+228
View File
@@ -0,0 +1,228 @@
import jwt from "jsonwebtoken";
import {
prisma,
refreshTokenDuration,
accessTokenDuration,
accessTokenPrivateKey,
refreshTokenPrivateKey,
} from "../constants";
import SessionTokenError from "../Errors/SessionTokenError";
import { events } from "../modules/globalEvents";
import UserController from "./UserController";
import { users } from "../managers/users";
import { Session } from "../../generated/prisma/client";
export interface SessionPayloadObject {
userID: string;
sessionKey: string;
}
export interface SessionTokensObject {
accessToken: string;
refreshToken: string;
}
export type DecodedSession = SessionPayloadObject & {
iat: number;
exp: number;
};
/**
* Session Controller
*
* This class is for create a controller that can manage, generate and refresh tokens, self terminate and self delete
* all sessions. This also allows you to access all data about a session.
*/
export class SessionController {
public readonly id: string;
public readonly sessionKey: string;
public readonly userId: string;
public readonly expires: Date;
public refreshedAt: Date | null;
public invalidatedAt: Date | null;
public terminated: boolean = false;
private _refreshTokenGenrated: boolean;
constructor(sessionData: Session) {
this.id = sessionData.id;
this.sessionKey = sessionData.sessionKey;
this.userId = sessionData.userId;
this.expires = sessionData.expires;
this.refreshedAt = sessionData.refreshedAt;
this.invalidatedAt = sessionData.invalidatedAt;
this._refreshTokenGenrated = sessionData.refreshTokenGenerated;
}
/** @ignore */
private _generateAccessToken() {
const payload: SessionPayloadObject = {
sessionKey: this.sessionKey,
userID: this.userId,
};
return jwt.sign(payload, accessTokenPrivateKey, {
algorithm: "RS256",
expiresIn: accessTokenDuration,
});
}
/** @ignore */
private _generateRefreshToken() {
const payload: SessionPayloadObject = {
sessionKey: this.sessionKey,
userID: this.userId,
};
return jwt.sign(payload, refreshTokenPrivateKey, {
algorithm: "RS256",
expiresIn: refreshTokenDuration,
});
}
/**
* Invalidate the Session
*
* The purpose for this function is if you wanted to be able to listen for somebody using an invalid session,
* you just have to invalidate it with this function and go from there.
*
* @returns {Promise<void>} - nothing
*/
public async invalidate() {
const invalidationDate = new Date();
if (this.invalidatedAt)
throw new Error("Session has already been invalidated.");
await prisma.session.update({
data: { invalidatedAt: invalidationDate },
where: { id: this.id },
});
this.invalidatedAt = invalidationDate;
events.emit("session:invalidated", this);
return;
}
/**
* Terminate the session
*
* Terminating the session will immediately delete the session making it impossible for it to be referenced again.
*
* @returns {Promise<void>} - nothing
*/
public async terminate() {
await prisma.session.delete({ where: { id: this.id } });
events.emit("session:terminated", this);
this.terminated = true;
return;
}
/**
* Generate Tokens
*
* **NOTE**: This method can only be ran once per session.
*
* Running this function will allow you to generate an accessToken and a refreshToken. Each token
* will be generated with their own respective private keys.
*
*
* @returns {Promise<SessionTokensObject>} An object containing the `accessToken` and `refreshToken`
*/
public async generateTokens(): Promise<SessionTokensObject> {
if (this._refreshTokenGenrated)
throw new Error("Tokens have alredy been generated for this session.");
const accessToken = this._generateAccessToken();
const refreshToken = this._generateRefreshToken();
const newRefreshDate = new Date();
await prisma.session.update({
data: { refreshTokenGenerated: true, refreshedAt: newRefreshDate },
where: { id: this.id },
});
this._refreshTokenGenrated = true;
this.refreshedAt = newRefreshDate;
let tokens = { accessToken, refreshToken };
events.emit("session:tokens_generated", { session: this, tokens });
return tokens;
}
/**
* Refresh the session
*
* Refreshing the session will generate a new accessToken for the user to authenticate their requests with.
*
* **NOTE**: Best practice when implementing token refreshing into the UI is that if for any reason this method
* throws an error, imediately purge the existing tokens and have the user login again. This way you don't hold
* them up any longer than necessary.
*
* @param refreshToken - The refresh token provided at session generation
* @returns {Promise<string>} The new access token.
*/
public refresh(refreshToken: string): Promise<string> {
return new Promise(async (res, rej) => {
if (this.expires.getTime() <= Date.now()) {
await this.terminate();
throw new SessionTokenError("Session has Expired.");
}
jwt.verify(
refreshToken,
refreshTokenPrivateKey,
{
algorithms: ["RS256"],
},
async (err, decode) => {
if (err) {
if (
err.name == "TokenExpiredError" ||
err.message == "invalid signature"
)
this.terminate();
rej(err);
}
const data: DecodedSession = decode as DecodedSession;
if (
data.sessionKey !== this.sessionKey ||
data.userID !== this.userId
)
rej(
new SessionTokenError(
"Refresh token does not match this session."
)
);
await prisma.session.update({
data: { refreshedAt: new Date() },
where: { id: this.id },
});
const newToken = this._generateAccessToken();
events.emit("session:token_refresh", {
session: this,
tokens: { accessToken: newToken, refreshToken },
});
return res(newToken);
}
);
});
}
/**
* Fetch Session User
*
* Fetch the user controller of the user that created the session.
*
* @returns {Promise<UserController>} The user that created this session.
*/
public async fetchUser(): Promise<UserController> {
return (await users.fetchUser({ id: this.userId }))!;
}
}
+200
View File
@@ -0,0 +1,200 @@
import { Collection } from "@discordjs/collection";
import { z } from "zod";
import { Role } from "../../generated/prisma/client";
import { User } from "../../generated/prisma/browser";
import { SessionTokensObject } from "./SessionController";
import { sessions } from "../managers/sessions";
import BodyError from "../Errors/BodyError";
import { prisma } from "../constants";
import { events } from "../modules/globalEvents";
import { RoleController } from "./RoleController";
import { roles } from "../managers/roles";
export default class UserController {
public id: string;
public name: string | null;
public login: string;
public email: string;
public image: string | null;
private _roles: Collection<string, Role>;
public createdAt: Date;
public updatedAt: Date;
constructor(userdata: User & { roles: Role[] }) {
this.id = userdata.id;
this.name = userdata.name;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
this._roles = (() => {
let collection = new Collection<string, Role>();
userdata.roles.map((v: any) => collection.set(v.id, v));
return collection;
})();
}
/**
* Update the internal values
*
* This is an internal method used to update all the internal values when we query the database. This way
* everything stays upto date even when we pass around the user controller.
*
* @param userdata - User object from Prisma
*/
private _updateInternalValues(userdata: User) {
this.id = userdata.id;
this.name = userdata.name;
this.login = userdata.login;
this.email = userdata.email;
this.image = userdata.image;
this.updatedAt = userdata.updatedAt;
this.createdAt = userdata.createdAt;
}
/**
* Create Session
*
* This will create a session in the database that is linked to the user and will then create a pair of access and refresh
* tokens to provide to the user such that they can authorized their api requests.
*
* @returns {Promise<SessionTokensObject>} - Object with an access token and a refresh token.
*/
public async createSession(): Promise<SessionTokensObject> {
return sessions.create({ user: this });
}
/**
* Update the user
*
* Take in a partial of the user data and validate it then updated it if it passes validation and return
* the updated `UserController` object.
*
* @param data - A partial of the user data
* @returns {Promise<UserController>} - The updated user controller
*/
public async update(data: Partial<User>) {
// Parsed Data With Schema
const pData = z
.object({
name: z.string().optional(),
image: z.string().optional(),
})
.strict()
.parse(data);
if (Object.keys(data).length == 0)
throw new BodyError("Body cannot be empty.");
const updatedUser = await prisma.user.update({
where: { id: this.id },
data: pData,
});
this._updateInternalValues(updatedUser);
events.emit("user:updated", { user: this, updatedValues: data });
return this;
}
/**
* Fetch Roles
*
* This method will fetch all of the roles that a user belongs to and will return each of their controllers in a collection
* of role id's and RoleControllers.
*
* @returns {Promise<Collection<string, RoleController>>} A collection of all the roles a user has
*/
public async fetchRoles(): Promise<Collection<string, RoleController>> {
const collection = new Collection<string, RoleController>();
await Promise.all(
this._roles.map(async (v) =>
collection.set(v.id, await roles.fetch(v.id))
)
);
return collection;
}
/**
* Check Permission
*
* Check if this user has this specific permission. This method will not only check explicit permissions defined in
* the database under users and roles, but will also generate implicit permissions for resources that the user has
* access to but doesn't specifically have defined under any given permissions object.
*
* @param permission - The permission to check for
* @returns {boolean} Does this user have the specified permission
*/
public async hasPermission(permission: string) {
let resources = await prisma.user.findFirst({
where: { id: this.id },
select: {
sessions: {
select: { id: true },
},
apiKeys: {
select: { id: true },
},
projects: {
select: { id: true },
},
services: {
select: { id: true },
},
},
});
const implicitPermissions = Object.keys(resources ?? {})
.filter((v) => resources![v].length > 0)
.map(
(v) =>
`resource.${v}.[${(resources![v] as { id: string }[])
.map((o) => o.id)
.join(",")}].user.${this.id}.implicit`
);
console.log(implicitPermissions);
let checks = [
(await this.fetchRoles()).map((v) => v.checkPermission(permission)),
].flatMap((v) => v);
return checks.includes(true);
}
/**
* To JSON
*
* Create an object that can be safely returned to the user of an api request such that when you
* need to return data to the end user, you don't accidently return data that could be harmful
* if leaked.
*
* Options:
* - Safe return is to return only data that is considered "safe", and not detrimental to pass around
*
* @param opts - Options to change the output
* @returns - An object that is JSON friendly
*/
public toJson(opts?: { safeReturn: boolean }) {
return {
id: this.id,
name: this.name,
roles: opts?.safeReturn
? undefined
: this._roles.size > 0
? this._roles.map((v) => v.moniker)
: undefined,
login: opts?.safeReturn ? undefined : this.login,
email: opts?.safeReturn ? undefined : this.email,
image: this.image,
};
}
}
+7 -1
View File
@@ -1 +1,7 @@
console.log("Hello via Bun!");
import app from "./api/server";
import { PORT } from "./constants";
Bun.serve({
port: PORT,
fetch: app.fetch
});
+171
View File
@@ -0,0 +1,171 @@
import { Collection } from "@discordjs/collection";
import GenericError from "../Errors/GenericError";
import RoleError from "../Errors/RoleError";
import { prisma } from "../constants";
import { RoleController } from "../controllers/RoleController";
import { signPermissions } from "../modules/permission-utils/signPermissions";
import cuid from "cuid";
import UserController from "../controllers/UserController";
import InsufficientPermission from "../Errors/InsufficientPermission";
import { z } from "zod";
export const roles = {
/**
* Create a role
*
* @param data - Data required to make a role
* @returns {RoleController} - The new role
*/
async create(data: {
title: string;
moniker: string;
permissions?: string[];
}) {
const schema = z.object({
title: z.string().min(1, "Title cannot be empty"),
moniker: z.string().min(1, "Moniker cannot be empty"),
permissions: z
.array(z.string().min(1, "Cannot have a blank permission node."))
.optional(),
});
data = await schema.parseAsync(data);
const checkMoniker = await prisma.role.findFirst({
where: { moniker: data.moniker },
});
if (checkMoniker)
throw new RoleError(
"Moniker is already taken.",
"Another role with this moniker already exists in the databse."
);
const id = cuid();
const newRole = await prisma.role.create({
data: {
id,
title: data.title,
moniker: data.moniker,
permissions: signPermissions({
issuer: "roles",
subject: id,
permissions: data.permissions ?? [],
}),
},
include: {
users: {
include: {
roles: true,
},
},
},
});
const controller = new RoleController(newRole);
return controller;
},
/**
* Fetch a role
*
* Fetch a role using either it's id or it's moniker.
*
* @param identifier - Role `id` or `moniker`
* @param identifier - Options for fetching a role.
* @returns {RoleController} - Role Controller
*/
async fetch(identifier:string, opt?: { requestingUser?: UserController }) {
const roleData = await prisma.role.findFirst({
where: { OR: [{ id: identifier }, { moniker: identifier }] },
include: {
users: {
include: {
roles: true,
},
},
},
});
if (!roleData)
throw new GenericError({
name: "UnknownRole",
message: "Unknown role...",
status: 404,
});
if (
opt?.requestingUser &&
!(await opt.requestingUser.hasPermission(
this._buildPermissionNode(roleData.id, "read")
))
)
throw new InsufficientPermission(
"You do not have permission to access this role."
);
const controller = new RoleController(roleData);
return controller;
},
/**
* Fetch all roles
*
* This will give you all of the roles and their respective controllers
*
* @param opt - Options for fetching all roles.
* @returns {Collection<string, RoleController>} A collection of all the roles and their id's
*/
async fetchAllRoles(opt?: { requestingUser?: UserController }) {
let collection = new Collection<string, RoleController>();
const roles = await prisma.role.findMany({
include: { users: { include: { roles: true } } },
});
roles. map((v:any) => collection.set(v.id, new RoleController(v)));
if (opt?.requestingUser) {
const permittedRoles = await Promise.all(
collection.map(async (v) =>
(await opt.requestingUser?.hasPermission(
this._buildPermissionNode(v.id, "read")
))
? v.id
: null
)
);
collection = collection.filter((v) =>
permittedRoles.filter((x) => x !== null).includes(v.id)
);
}
return collection;
},
/**
* Build Permissino Node
*
* Build a role centric permission node with a role id and permission scope.
*
* **FORMAT**: `roles.{id}.{scope}
*
* @param id - Role ID
* @param scope - Scope of the permission node
* @returns {string} Constructed Permission Node
*/
_buildPermissionNode(id: string, scope: "read" | "write") {
return ["roles", id, scope].join(".");
},
};
/**
* @TODO automatic string transformation for monikers to be all lower case with no spaces and only underscores.
*
* @TODO create and inheritance and ordering system @see https://www.prisma.io/docs/concepts/components/prisma-schema/relations/self-relations
*
* @TODO go through and make sure all the zod schemas have mins, maxes, etc.
*
* @TODO Limit those who can give out the `*` permission to those with the `*` permission.
* - Maybe also limit the use of `*` all together?
*/
+159
View File
@@ -0,0 +1,159 @@
import {
prisma,
refreshTokenDuration,
sessionDuration,
accessTokenDuration,
accessTokenPrivateKey,
refreshTokenPrivateKey,
} from "../constants";
import UserController from "../controllers/UserController";
import {
SessionController,
DecodedSession,
SessionPayloadObject,
SessionTokensObject,
} from "../controllers/SessionController";
import jwt from "jsonwebtoken";
import SessionError from "../Errors/SessionError";
import SessionTokenError from "../Errors/SessionTokenError";
import ExpiredAccessTokenError from "../Errors/ExpiredAccessTokenError";
import ExpiredRefreshTokenError from "../Errors/ExpiredRefreshTokenError";
import { events } from "../modules/globalEvents";
export interface SessionCreationData {
user: UserController;
}
export const sessions = {
/**
* Create a session
*
* This will create a session instance in the databse that will be linked to both the session token
* and the refresh token.
*
* @param data - The params needed to create the session tokens
* @returns {Promise<SessionTokensObject>} Session Token and the Refresh Token
*/
async create(data: SessionCreationData): Promise<SessionTokensObject> {
const session = await prisma.session.create({
data: {
expires: new Date(Date.now() + sessionDuration),
userId: data.user.id,
},
});
const controller = new SessionController(session);
// Trigger Global Event
events.emit("session:created", {
user: data.user,
session: controller,
});
let tokens: SessionTokensObject = await controller.generateTokens();
return tokens;
},
/**
* Fetch a session
*
* This method is designed to be as versitile as possible, if you are looking for a session, use this method with
* your choice of `accessToken`, `refreshToken`, `id`, or `sessionKey`. The identifier value is a partial type.
*
* @param identifier - An object allowing you to put either session token, sessionKey, or id in to fetch the desired session.
* @returns {Promise<SessionController>} The controller for the desired session.
*/
async fetch(
identifier: Partial<{
refreshToken: string;
accessToken: string;
id: string;
sessionKey: string;
}>
) {
if (identifier.refreshToken || identifier.accessToken) {
const decodedJWT = identifier.refreshToken
? ((await new Promise((res, rej) =>
jwt.verify(
identifier.refreshToken!,
refreshTokenPrivateKey,
{
algorithms: ["RS256"],
},
async (err, decode) => {
if (
err &&
(err.name == "TokenExpiredError" ||
err.message == "invalid signature")
) {
let sessionDat = await prisma.session.findFirst({
where: {
sessionKey: (
jwt.decode(identifier.refreshToken!) as DecodedSession
).sessionKey,
},
});
if (!sessionDat)
return rej(new SessionError("Invalid session."));
let session = new SessionController(sessionDat);
await session.terminate();
if (err.message == "invalid signature")
return rej(new SessionError("Invalid session."));
return rej(new ExpiredRefreshTokenError("It epired."));
}
if (err) return rej(err);
return res(decode as DecodedSession);
}
)
)) as DecodedSession)
: ((await new Promise((res, rej) =>
jwt.verify(
identifier.accessToken!,
accessTokenPrivateKey,
{
algorithms: ["RS256"],
},
(err, decode) => {
if (err && err.name == "TokenExpiredError")
return rej(new ExpiredAccessTokenError());
if (err) return rej(err);
return res(decode as DecodedSession);
}
)
)) as DecodedSession);
const sessionData = await prisma.session.findFirst({
where: { sessionKey: decodedJWT.sessionKey },
});
if (!sessionData) throw new SessionError("Invalid Session");
if (identifier.accessToken && decodedJWT.exp > Date.now())
throw new ExpiredAccessTokenError();
if (identifier.refreshToken && decodedJWT.exp > Date.now()) {
let sess = new SessionController(sessionData);
await sess.terminate();
throw new SessionError("Invalid Session...", "Expired Refresh Token");
}
return new SessionController(sessionData);
}
const sessionData = await prisma.session.findFirst({
where: { OR: [{ sessionKey: identifier.sessionKey, id: identifier.id }] },
});
if (!sessionData) throw new SessionError("Invalid Session");
return new SessionController(sessionData);
},
};
/**
* @TODO As a consequence of the above, need to setup pgBoss for cron and event loop.
*/
+118
View File
@@ -0,0 +1,118 @@
import { User } from "../../generated/prisma/client";
import { prisma } from "../constants";
import { SessionTokensObject } from "../controllers/SessionController";
import UserController from "../controllers/UserController";
export const users = {
/**
* Authenticate The User
*
* If the user has already been registered, this function will supply them with a session id, otherwise
* this method will create an account, then create a session id.
*
* @summary It creates a user if one doesn't exist and will supply a session id
*
* @async
* @param ghCode - The code supplied in the callback url of a GitHub oAuth transaction
*/
/* async authenticate(ghCode: string): Promise<SessionTokensObject> {
const token = await ghApp.oauth.createToken({ code: ghCode }).catch((e) => {
throw new AuthenticationError("Invalid OAuth code...");
});
const userOK = await ghApp.oauth.getUserOctokit({
token: token.authentication.token,
});
const ghUser = await userOK.request("GET /user");
let user =
(await this.fetchUser({ userId: ghUser.data.id })) ??
(await this.createUser(token.authentication.token));
const tokens = await sessions.create({ user });
events.emit("user:authenticated", { user, tokens });
return tokens;
}, */
/**
* Check to see if the user exists
*
* @param partial - A partial object that you can feed any value from the database into.
* @returns {Promise<boolean>} Does the user exist?
*/
async userExists(partial: Partial<User>): Promise<boolean> {
const match = await prisma.user.findFirst({ where: partial });
return !!match;
},
/**
* Fetch a user
*
* This method takes in a unique identifier for the user you are trying to fetch, and returns
* the controller for the user if the user exists. If the user doesn't exist, or no identifier
* was provided, this will reutrn `null`.
*
* @param identifier - A partial identifier for a user (e.g. id, email, userID, etc.)
* @returns {Promise<UserController>} The controller for the user
*/
async fetchUser(
identifier: Partial<{
id: string;
email: string;
login: string;
userId: number;
}>
) {
if (Object.keys(identifier).length == 0) return null;
const userData = await prisma.user.findFirst({
where: {
//@ts-ignore
OR: Object.keys(identifier).map((v) => ({ [v]: identifier[v] })),
},
include: { roles: true },
});
if (!userData) return null;
return new UserController(userData);
},
/**
* Create a new user
*
* This method will poll GitHub and get all the information on the user to then create the
* record in our database. On top of that it also pushes it into the user cache.
*
* @param token - The Github token provided by the auth method
* @returns {Promise<UserController>} The new user controller for the user
*/
async createUser(token: string): Promise<UserController> {
const ghUser = await (
await ghApp.oauth.getUserOctokit({ token })
).request("GET /user");
const emails = await (
await ghApp.oauth.getUserOctokit({ token })
).request("GET /user/emails");
const newUser = await prisma.user.create({
data: {
userId: ghUser.data.id,
email: emails.data[0].email,
image: ghUser.data.avatar_url,
name: ghUser.data.name,
login: ghUser.data.login,
token,
},
include: { roles: true },
});
let controller = new UserController(newUser);
events.emit("user:created", controller);
return controller;
},
};
/**
* @TODO Figure out default permissions
*/
+56
View File
@@ -0,0 +1,56 @@
import { ZodError } from "zod";
/**
* @ignore
*/
export const apiResponse = {
successful: (message: string, data?: any) => ({
status: 200,
message,
data,
successful: true,
meta: {
timestamp: Date.now(),
},
}),
created: (message: string, data?: any) => ({
status: 201,
message,
data,
successful: true,
meta: {
timestamp: Date.now(),
},
}),
error: (err: Error) => ({
// @ts-ignore
status: err["status"] ?? 400,
message: err.message,
error: err.name,
successful: false,
meta: {
timestamp: Date.now(),
},
}),
internalError: () => ({
status: 500,
message: "An Internal Server Error has occured...",
error: "InternalServerError",
successful: false,
meta: {
timestamp: Date.now(),
},
}),
zodError: (err: ZodError) => {
const data = JSON.parse(err.message);
return {
status: 400,
message: "TypeError",
error: data,
successful: false,
meta: {
timestamp: Date.now(),
},
};
},
};
+40
View File
@@ -0,0 +1,40 @@
import { Handler, Hono, MiddlewareHandler } from "hono";
import { Variables } from "../../types/HonoTypes";
/**
* Create a route.
*
* This method exists to serve the purpose of allowing us to split all of our api routes into different files and
* easily and quickly be able to rope them back into the main api server instance.
*
* One of the sortfallings of this method is that I was not able to figure out how to integrate the middleware to come
* before the handler, so if somebody feels upto it please figure out a way to have the middleware come before the handler
* method naturally as you would if you using a plain hono method.
*
* @TODO Move middleware handlers to come before primary handler naturally.
*
* @param method - HTTP Method
* @param path - URL Path
* @param handler - Handler function for Hono
* @param middleware - Array of Middleware Handlers for Hono
* @returns {Hono} - A new Hono instance containing the newly created route.
*/
export function createRoute(
method: string | string[],
path: string[],
handler: Handler<{
Variables: Variables;
}>,
...middleware: MiddlewareHandler<{
Variables: Variables;
}>[]
): Hono<{ Variables: Variables }> {
if (middleware)
return new Hono<{ Variables: Variables }>().on(
method as any,
path,
...middleware,
handler
);
return new Hono<{ Variables: Variables }>().on(method as any, path, handler);
}
+64
View File
@@ -0,0 +1,64 @@
import { Eventra } from "@duxcore/eventra";
import UserController from "../controllers/UserController";
import {
SessionController,
SessionTokensObject,
} from "../controllers/SessionController";
import { RoleController } from "../controllers/RoleController";
import { JsonWebTokenError } from "jsonwebtoken";
import { User } from "../../generated/prisma/client";
interface EventTypes {
"api:started": () => void;
"user:created": (user: UserController) => void;
"user:updated": (data: {
user: UserController;
updatedValues: Partial<User>;
}) => void;
"user:authenticated": (data: {
user: UserController;
tokens: SessionTokensObject;
}) => void;
"session:created": (data: {
user: UserController;
session: SessionController;
}) => void;
"session:tokens_generated": (data: {
session: SessionController;
tokens: SessionTokensObject;
}) => void;
"session:token_refresh": (data: {
session: SessionController;
tokens: SessionTokensObject;
}) => void;
"session:invalidated": (session: SessionController) => void;
"session:terminated": (session: SessionController) => void;
"role:created": (role: RoleController) => void;
"role:deleted": (role: RoleController) => void;
"role:updated": (data: {
role: RoleController;
updateData: Parameters<typeof RoleController.prototype.update>["0"];
}) => void;
"role:permissions:updated": (data: {
previous: string[];
previousSigned: string;
current: string[];
currentSigned: string;
action: "set" | "added" | "removed";
role: RoleController;
}) => void;
"role:permissions:verification_error": (data: {
currentSigned: string;
attemptedVerification: string;
err: Error;
role: RoleController;
}) => void;
}
export const events = new Eventra<EventTypes>();
export function setupEventDebugger() {
events.any((eventName, ...args) => {
console.log(`[ Event Debugger ] (${eventName})`);
});
}
@@ -0,0 +1,9 @@
export function genImplicitPerm(
resource: string,
resourceId: string,
userId: string
) {
return ["resource", resource, resourceId, "user", userId, "implicit"].join(
"."
);
}
@@ -0,0 +1,53 @@
/**
* Permission Validator
*
* This method is used for validaing user and role permissions. This method is given a single or and array
* of permission nodes that the user has and it is also given the permission node that is required for whatever
* query they are trying to execute, and this will determine if any of the permission nodes match or will
* verify the given permission node.
*
* Special token types:
* - Asterisk (*): verifies it's token and all following tokens.
* - Question Mark (?): verifies it's token and only it's token.
* - Inclusive List ([a,b,c]): verifies only the tokens in the list.
* - Exclusive List (<a,b,c>): verifies all tokens except for the ones in the list.
*
* @param permission - The required permission
* @param permissionExpressions - The owned permission(s)
* @returns {boolean} Does the user have the permission?
*/
export function permissionValidator(
permission: string,
permissionExpressions: string | string[]
): boolean {
if (typeof permissionExpressions === "string") {
// If the second parameter is a string, treat it as a single expression
permissionExpressions = [permissionExpressions];
}
// Iterate over each expression in the array and check if any of them match the permission
for (const expression of permissionExpressions) {
const rx = expression
.replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
.replace(/\[([^\]\[]*)\]/g, "($1)")
.replace(/<([^<>]+)>/g, "(?:(?!$1)[^.])*")
.replace(/,/g, "|");
if (new RegExp(`^${rx}$`).test(permission)) {
return true;
}
}
return false;
}
/**
* @TODO It's okay, you can't always get everything done and that is fine.
* Just take a breath and move on, come back if you feel upto it.
* What you make is good and whilst you can always do more,
* you can't do everything. Nothing will ever be perfect,
* so stop trying to be perfect and allow your self to move on
* even if you know there is more you can do.
*/
@@ -0,0 +1,24 @@
import jwt from "jsonwebtoken";
import { permissionsPrivateKey } from "../../constants";
import { PermissionIssuers } from "../../types/PermissionTypes";
/**
* Sign Permissions
*
* This will sign the array of permissions with the private key for permissions, and then return
* a JWT which will be stored in the databse.
*
* @param permissions - All the permissions to be signed
* @returns {string} - The signed permissions object
*/
export function signPermissions(data: {
issuer: PermissionIssuers;
subject: string;
permissions: string[];
}) {
return jwt.sign({ permissions: data.permissions }, permissionsPrivateKey, {
algorithm: "RS256",
issuer: data.issuer,
subject: data.subject,
});
}
+34
View File
@@ -0,0 +1,34 @@
import { blake2sHex } from "blakets";
import crypto from "crypto";
export default class Password {
public static generateSalt(options?: GenerateSaltOptions): string {
const length = options?.length ?? 12;
const randomBytes = crypto.randomBytes(Math.ceil(length / 2));
return randomBytes.toString("hex").slice(0, length);
}
public static hash(password: string, options?: HashPasswordOptions): string {
const salt =
options?.overrideSalt ?? Password.generateSalt(options?.saltOpts);
const hash = blake2sHex(`$BLAKE2s$${password}$${salt}`);
return `BLAKE2s$${hash}$${salt}`;
}
public static validate(newPass: string, hashed: string): boolean {
const [algo, oldHash, salt] = hashed.split(/\$/g);
return crypto.timingSafeEqual(
Buffer.from(hashed),
Buffer.from(Password.hash(newPass, { overrideSalt: salt }))
);
}
}
export interface HashPasswordOptions {
overrideSalt?: string;
saltOpts?: GenerateSaltOptions;
}
export interface GenerateSaltOptions {
length?: number; // default 12
}
+8
View File
@@ -0,0 +1,8 @@
export const mergeArrays = (a, b, predicate = (a, b) => a === b) => {
const c = [...a]; // copy to avoid side effects
// add all items from B to copy C if they're not already present
b.forEach((bItem) =>
c.some((cItem) => predicate(bItem, cItem)) ? null : c.push(bItem)
);
return c;
};
+3
View File
@@ -0,0 +1,3 @@
export type Variables = {
foo: "bar"
};
+7
View File
@@ -0,0 +1,7 @@
export type PermissionIssuers = "roles" | "user" | "api_key";
export interface DecodedPermissionsBlock {
permissions: string[];
iat: number; // Issued at
iss: PermissionIssuers; // Issuer
sub: string; // Subject
}