1.1.0 #29

Merged
Xominus merged 25 commits from discord into main 2026-08-22 18:00:22 +00:00
8 changed files with 54 additions and 51 deletions
Showing only changes of commit 4dbcccdef7 - Show all commits
+1 -1
View File
@@ -27,7 +27,7 @@
"@elysia/jwt": "^1.4.2", "@elysia/jwt": "^1.4.2",
"@elysia/server-timing": "^1.4.1", "@elysia/server-timing": "^1.4.1",
"@elysiajs/cors": "^1.4.2", "@elysiajs/cors": "^1.4.2",
"@types/pg": "^8.21.0", "@types/pg": "^8.23.1",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"kysely": "^0.29.5", "kysely": "^0.29.5",
"ml-levenberg-marquardt": "^5.1.0", "ml-levenberg-marquardt": "^5.1.0",
@@ -11,6 +11,8 @@ export async function up(db: Kysely<any>): Promise<void> {
) )
.addColumn("discord_id", "text", (cb) => cb.unique()) .addColumn("discord_id", "text", (cb) => cb.unique())
.addColumn("email", "text", (cb) => cb.unique()) .addColumn("email", "text", (cb) => cb.unique())
.addColumn("password_hash", "text")
.addColumn("role", "text", (cb) => cb.notNull().defaultTo("user"))
.addColumn("name", "text", (cb) => cb.notNull().defaultTo("Anonymous")) .addColumn("name", "text", (cb) => cb.notNull().defaultTo("Anonymous"))
.addColumn("gender", "text") .addColumn("gender", "text")
.execute(); .execute();
+10 -28
View File
@@ -1,11 +1,11 @@
import { import {
StandardsConfigSchema, StandardsConfigSchema,
} from "@blade-and-brawn/calculator"; } from "@blade-and-brawn/calculator";
import { Gender } from "@blade-and-brawn/domain";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
import { db } from "./db"; import { db } from "./db";
import { DEFAULT_NAME, env, log } from "../util"; import { DEFAULT_NAME, env, log } from "../util";
import standardsConfig from "./seed-data/standards-config.json" with {type: "json"}; import standardsConfig from "./seed-data/standards-config.json" with {type: "json"};
import type { AccountRole } from "../services/accounts";
Value.Assert(StandardsConfigSchema, standardsConfig); Value.Assert(StandardsConfigSchema, standardsConfig);
@@ -69,44 +69,26 @@ async function seedCalculator(standardsConfigId: string): Promise<void> {
log.info({ id: result.id }, "seeded default calculator"); log.info({ id: result.id }, "seeded default calculator");
} }
async function seedAccounts(): Promise<void> { async function seedAdminAccount(): Promise<void> {
const ACCOUNT_NAME = "Xominus";
const existing = await db.selectFrom("accounts") const existing = await db.selectFrom("accounts")
.select(["id"]) .select(["id"])
.where("name", "=", ACCOUNT_NAME) .where("email", "=", env.PUBLIC_ADMIN_EMAIL)
.executeTakeFirst(); .executeTakeFirst();
if (existing) { if (existing) {
log.info({ id: existing.id }, "default account already seeded, skipping"); log.info({ id: existing.id }, "admin account already seeded, skipping");
return; return;
} }
const account = await db.insertInto("accounts") const account = await db.insertInto("accounts")
.values({ .values({
name: ACCOUNT_NAME, name: "Admin",
discord_id: "1325552430942916731", email: env.PUBLIC_ADMIN_EMAIL,
email: "xominus@bladeandbrawn.com" role: "admin" satisfies AccountRole,
password_hash: env.ADMIN_PASSWORD_HASH
}) })
.returning("id") .returning("id")
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
log.info({ id: account.id }, "seeded default account"); log.info({ id: account.id }, "seeded admin account");
await db.insertInto("assessments")
.values({
account_id: account.id,
name: ACCOUNT_NAME,
gender: Gender.Male,
age: 25,
weight: 80,
perf_back_squat: 72.57472,
perf_deadlift: 127.00576,
perf_bench_press: 102.0582,
perf_run: 446000,
perf_broad_jump: 185.42000000000002,
perf_cone_drill: 9870,
})
.execute();
log.info({ accountId: account.id }, "seeded placeholder assessment");
} }
(async () => { (async () => {
@@ -121,7 +103,7 @@ async function seedAccounts(): Promise<void> {
const datasetId = await seedStandardsDataset(); const datasetId = await seedStandardsDataset();
const standardsConfigId = await seedStandardsConfig(datasetId); const standardsConfigId = await seedStandardsConfig(datasetId);
await seedCalculator(standardsConfigId); await seedCalculator(standardsConfigId);
await seedAccounts(); await seedAdminAccount();
log.info("seed finished"); log.info("seed finished");
} }
catch (err) { catch (err) {
+20 -15
View File
@@ -20,7 +20,7 @@ import { CalculatorService, CalculatorUnavailableError } from "./services/calcul
import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator";
import { StandardsService } from "./services/standards"; import { StandardsService } from "./services/standards";
import { EventsService, EventStatusSchema } from "./services/events"; import { EventsService, EventStatusSchema } from "./services/events";
import { AccountsService } from "./services/accounts"; import { AccountsService, type AccountRole } from "./services/accounts";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
// CONSTANTS // CONSTANTS
@@ -56,15 +56,15 @@ const authPlugin = new Elysia({ name: "auth" })
authAdmin: { authAdmin: {
async resolve({ jwt, cookie: { auth } }) { async resolve({ jwt, cookie: { auth } }) {
const token = auth.value && await jwt.verify(auth.value); const token = auth.value && await jwt.verify(auth.value);
if (!token || token.role !== "admin" || !token.sessionId) throw status(401, "Unauthorized"); if (!token || token.role !== "admin" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized");
return { sessionId: token.sessionId.toString() }; return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() };
} }
}, },
authUser: { auth: {
async resolve({ jwt, cookie: { auth } }) { async resolve({ jwt, cookie: { auth } }) {
const token = auth.value && await jwt.verify(auth.value); const token = auth.value && await jwt.verify(auth.value);
if (!token || token.role !== "user" || !token.accountId || !token.sessionId) throw status(401, "Unauthorized"); if (!token || !token.role || !token.accountId || !token.sessionId) throw status(401, "Unauthorized");
return { sessionId: token.sessionId.toString(), accountId: token.accountId.toString() }; return { role: token.role.toString(), sessionId: token.sessionId.toString(), accountId: token.accountId.toString() };
} }
} }
}); });
@@ -137,22 +137,27 @@ export const app = new Elysia()
.get("/health", () => ({ status: "ok" })) .get("/health", () => ({ status: "ok" }))
// AUTHENTICATION // AUTHENTICATION
.post("/auth/admin/login", async ({ jwt, body, cookie: { auth } }) => { .post("/auth/login", async ({ jwt, body: { password, email }, cookie: { auth } }) => {
const match = crypto.timingSafeEqual(sha256Sum(body.password), Buffer.from(env.ADMIN_PASSWORD, "hex")); const account = await s.Accounts.getByEmail(email);
if (!match) throw status(401, "Invalid credentials"); const password_match = crypto.timingSafeEqual(
sha256Sum(password),
account?.password_hash ? Buffer.from(account?.password_hash, "hex") : sha256Sum("Dummy")
);
if (!account?.password_hash || !password_match) throw status(401, "Invalid credentials");
auth.set({ auth.set({
value: await jwt.sign({ role: "admin", sessionId: randomUUIDv7(), exp: JWT_EXP }), value: await jwt.sign({ role: account.role, sessionId: randomUUIDv7(), accountId: account.id, exp: JWT_EXP }),
path: "/", path: "/",
maxAge: JWT_EXP_SECONDS, maxAge: JWT_EXP_SECONDS,
sameSite: "lax", sameSite: env.NODE_ENV === "production" ? "lax" : "none",
httpOnly: true, httpOnly: true,
secure: env.NODE_ENV === "production", secure: true,
domain: env.NODE_ENV === "production" ? domain: env.NODE_ENV === "production" ?
".bladeandbrawn.com" : ".bladeandbrawn.com" :
undefined undefined
}); });
}, { body: t.Object({ password: t.String() }) }) }, { body: t.Object({ email: t.String(), password: t.String() }) })
.group("/auth/discord", (app) => app .group("/auth/discord", (app) => app
.get("/login", async ({ cookie: { authDiscord } }) => { .get("/login", async ({ cookie: { authDiscord } }) => {
@@ -226,7 +231,7 @@ export const app = new Elysia()
// Authenticate // Authenticate
auth.set({ auth.set({
value: await jwt.sign({ role: "user", sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }), value: await jwt.sign({ role: "user" satisfies AccountRole, sessionId: randomUUIDv7(), accountId, exp: JWT_EXP }),
path: "/", path: "/",
maxAge: JWT_EXP_SECONDS, maxAge: JWT_EXP_SECONDS,
sameSite: env.NODE_ENV === "production" ? "lax" : "none", sameSite: env.NODE_ENV === "production" ? "lax" : "none",
@@ -457,7 +462,7 @@ export const app = new Elysia()
}, { }, {
params: t.Object({ id: t.String() }) params: t.Object({ id: t.String() })
}) })
.guard({ authUser: true }, (app) => app .guard({ auth: true }, (app) => app
.get("/me/stats", async ({ accountId }) => { .get("/me/stats", async ({ accountId }) => {
const stats = await s.Accounts.stats(accountId); const stats = await s.Accounts.stats(accountId);
if (!stats) throw new NotFoundError("Account stats not found"); if (!stats) throw new NotFoundError("Account stats not found");
+11 -2
View File
@@ -1,9 +1,11 @@
import { Activity, Gender, type Player } from "@blade-and-brawn/domain"; import { Activity, Gender } from "@blade-and-brawn/domain";
import { db } from "../database/db"; import { db } from "../database/db";
import type { CalculatorService } from "./calculator"; import type { CalculatorService } from "./calculator";
import { Value } from "@sinclair/typebox/value"; import { Value } from "@sinclair/typebox/value";
import { t } from "elysia"; import { t } from "elysia";
export type AccountRole = "user" | "admin";
export class AccountsService { export class AccountsService {
constructor(private Calculator: CalculatorService) { } constructor(private Calculator: CalculatorService) { }
@@ -26,11 +28,18 @@ export class AccountsService {
async get(id: string) { async get(id: string) {
return await (db).selectFrom("accounts") return await (db).selectFrom("accounts")
.select(["id", "discord_id", "name", "email", "gender"]) .select(["id", "discord_id", "name", "email", "gender", "role"])
.where(...AccountsService.idComparison(id)) .where(...AccountsService.idComparison(id))
.executeTakeFirst(); .executeTakeFirst();
} }
async getByEmail(email: string) {
return await (db).selectFrom("accounts")
.select(["id", "discord_id", "name", "email", "gender", "password_hash", "role"])
.where("email", "=", email)
.executeTakeFirst();
}
async stats(id: string) { async stats(id: string) {
const latestAssessment = await db.selectFrom("accounts") const latestAssessment = await db.selectFrom("accounts")
.innerJoin("assessments", "assessments.account_id", "accounts.id") .innerJoin("assessments", "assessments.account_id", "accounts.id")
+2 -1
View File
@@ -20,7 +20,8 @@ export const env = {
WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"), WEBFLOW_AUTH: requireEnv("WEBFLOW_AUTH"),
WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"), WEBFLOW_WEBHOOK_SECRET: requireEnv("WEBFLOW_WEBHOOK_SECRET"),
AUTH_SECRET: requireEnv("AUTH_SECRET"), AUTH_SECRET: requireEnv("AUTH_SECRET"),
ADMIN_PASSWORD: requireEnv("ADMIN_PASSWORD"), PUBLIC_ADMIN_EMAIL: requireEnv("PUBLIC_ADMIN_EMAIL"),
ADMIN_PASSWORD_HASH: requireEnv("ADMIN_PASSWORD_HASH"),
DATABASE_URL: requireEnv("DATABASE_URL"), DATABASE_URL: requireEnv("DATABASE_URL"),
DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"), DATABASE_POOL_MAX: requireEnv("DATABASE_POOL_MAX"),
MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"), MAX_WORKER_COUNT: requireEnv("MAX_WORKER_COUNT"),
+3 -3
View File
@@ -3,10 +3,10 @@
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@blade-and-brawn/api": "workspace:*", "@blade-and-brawn/api": "workspace:*",
"@sveltejs/kit": "^2.70.2", "@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"elysia": "^1.4.29", "elysia": "^1.4.29",
"svelte": "^5.56.9", "svelte": "^5.56.10",
"svelte-adapter-bun": "^1.0.1", "svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.7.6", "svelte-check": "^4.7.6",
"vite": "^7.3.6" "vite": "^7.3.6"
@@ -25,7 +25,7 @@
"@blade-and-brawn/domain": "workspace:*", "@blade-and-brawn/domain": "workspace:*",
"@elysia/eden": "^1.4.10", "@elysia/eden": "^1.4.10",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"daisyui": "^5.7.17", "daisyui": "^5.7.20",
"jose": "^6.2.9", "jose": "^6.2.9",
"tailwindcss": "^4.3.3" "tailwindcss": "^4.3.3"
} }
+5 -1
View File
@@ -2,6 +2,7 @@
import "../app.css"; import "../app.css";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { api } from "$lib/api"; import { api } from "$lib/api";
import { env } from "$env/dynamic/public";
let error = $state(""); let error = $state("");
let loading = $state(false); let loading = $state(false);
@@ -14,7 +15,10 @@
loading = true; loading = true;
try { try {
const { status } = await api.auth.admin.login.post({ password }); const { status } = await api.auth.login.post({
email: env.PUBLIC_ADMIN_EMAIL ?? "",
password,
});
if (status === 401) { if (status === 401) {
error = "Invalid password"; error = "Invalid password";