Initial commit

This commit is contained in:
Daniel Volz
2025-12-19 13:09:53 +01:00
commit 47f8494795
31 changed files with 4055 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import dotenv from "dotenv";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
const url = process.env.DATABASE_URL || "file:./data/medassist.db";
const client = createClient({ url });
export const db = drizzle(client);
+20
View File
@@ -0,0 +1,20 @@
import { migrate } from "drizzle-orm/libsql/migrator";
import { db } from "./client.js";
import { env } from "../plugins/env.js";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = join(__filename, "..");
async function main() {
const migrationsFolder = join(__dirname, "migrations");
await migrate(db, { migrationsFolder });
console.log("Migrations applied");
process.exit(0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
View File
+44
View File
@@ -0,0 +1,44 @@
import { sqliteTable, text, integer, real } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email", { length: 255 }).notNull().unique(),
passwordHash: text("password_hash", { length: 255 }).notNull(),
role: text("role", { length: 50 }).notNull().default("user"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name", { length: 100 }).notNull().unique(),
count: integer("count").notNull().default(0),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
stripSize: integer("strip_size").notNull().default(1),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const refreshTokens = sqliteTable("refresh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
tokenId: text("token_id", { length: 255 }).notNull().unique(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
export const settings = sqliteTable("settings", {
id: integer("id").primaryKey({ autoIncrement: true }),
smtpHost: text("smtp_host"),
smtpPort: integer("smtp_port"),
smtpUser: text("smtp_user"),
smtpPassEncrypted: text("smtp_pass_encrypted"),
smtpFrom: text("smtp_from"),
smtpSecure: integer("smtp_secure", { mode: "boolean" }).notNull().default(false),
emailsPerDay: integer("emails_per_day").notNull().default(3),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
+68
View File
@@ -0,0 +1,68 @@
import Fastify from "fastify";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import cookie, { CookieSerializeOptions } from "@fastify/cookie";
import jwt from "@fastify/jwt";
import { env } from "./plugins/env.js";
import { healthRoutes } from "./routes/health.js";
import { authRoutes } from "./routes/auth.js";
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
},
});
const origins = env.CORS_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
const accessTtlMinutes = parseInt(env.ACCESS_TOKEN_TTL_MIN, 10);
const refreshTtlDays = parseInt(env.REFRESH_TOKEN_TTL_DAYS, 10);
const baseCookieOptions: CookieSerializeOptions = {
httpOnly: true,
sameSite: "lax",
secure: env.NODE_ENV === "production",
path: "/",
maxAge: accessTtlMinutes * 60,
};
const refreshCookieOptions: CookieSerializeOptions = {
...baseCookieOptions,
maxAge: refreshTtlDays * 24 * 60 * 60,
};
app.decorate("config", {
accessSecret: env.JWT_SECRET,
refreshSecret: env.REFRESH_SECRET,
accessTtl: accessTtlMinutes,
refreshTtl: refreshTtlDays,
cookieOptions: baseCookieOptions,
refreshCookieOptions,
});
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: origins, credentials: true });
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
});
await app.register(cookie, { secret: env.COOKIE_SECRET });
await app.register(jwt, { secret: env.JWT_SECRET, cookie: { cookieName: "access_token", signed: false } });
await app.register(healthRoutes);
await app.register(authRoutes);
const start = async () => {
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" });
app.log.info(`Server running on ${env.PORT}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod";
import dotenv from "dotenv";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
DATABASE_URL: z.string().default("file:./data/medassist.db"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
JWT_SECRET: z.string().min(10),
REFRESH_SECRET: z.string().min(10),
COOKIE_SECRET: z.string().min(10),
CSRF_SECRET: z.string().min(10),
ACCESS_TOKEN_TTL_MIN: z.string().default("15"),
REFRESH_TOKEN_TTL_DAYS: z.string().default("14"),
});
export type Env = z.infer<typeof EnvSchema>;
export const env: Env = EnvSchema.parse(process.env);
+41
View File
@@ -0,0 +1,41 @@
import { FastifyInstance } from "fastify";
import { z } from "zod";
import argon2 from "argon2";
import { db } from "../db/client.js";
import { users, refreshTokens } from "../db/schema.js";
import { randomUUID } from "crypto";
import { eq } from "drizzle-orm";
const loginBody = z.object({ email: z.string().email(), password: z.string().min(6) });
export async function authRoutes(app: FastifyInstance) {
app.post("/auth/login", { config: { csrf: true } }, async (req, reply) => {
const parsed = loginBody.safeParse(req.body);
if (!parsed.success) {
return reply.badRequest("Invalid credentials");
}
const { email, password } = parsed.data;
const [user] = await db.select().from(users).where(eq(users.email, email));
if (!user) return reply.unauthorized();
const ok = await argon2.verify(user.passwordHash, password);
if (!ok) return reply.unauthorized();
const accessToken = app.jwt.sign({ sub: user.id, role: user.role }, { expiresIn: `${app.config.accessTtl}m` });
const tokenId = randomUUID();
const refreshExp = Math.floor(Date.now() / 1000) + app.config.refreshTtl * 24 * 60 * 60;
await db.insert(refreshTokens).values({ userId: user.id, tokenId, expiresAt: new Date(refreshExp * 1000) });
const refreshToken = app.jwt.sign({ sub: user.id, jti: tokenId }, { expiresIn: `${app.config.refreshTtl}d`, key: app.config.refreshSecret });
reply
.setCookie("access_token", accessToken, app.config.cookieOptions)
.setCookie("refresh_token", refreshToken, app.config.refreshCookieOptions)
.send({ ok: true });
});
app.post("/auth/logout", async (req, reply) => {
reply
.clearCookie("access_token", app.config.cookieOptions)
.clearCookie("refresh_token", app.config.refreshCookieOptions)
.send({ ok: true });
});
}
+5
View File
@@ -0,0 +1,5 @@
import { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async () => ({ status: "ok" }));
}
+14
View File
@@ -0,0 +1,14 @@
import "fastify";
declare module "fastify" {
interface FastifyInstance {
config: {
accessSecret: string;
refreshSecret: string;
accessTtl: number;
refreshTtl: number;
cookieOptions: import("@fastify/cookie").CookieSerializeOptions;
refreshCookieOptions: import("@fastify/cookie").CookieSerializeOptions;
};
}
}