mirror of
https://github.com/temetro/temetro.git
synced 2026-08-12 03:26:53 +00:00
Add TypeScript + Express + Postgres backend with Better Auth
Implements the first temetro backend: an Express 5 API on Postgres via Drizzle ORM, with authentication and multi-tenant clinics powered by Better Auth. - Auth: email/password with required email verification, password reset, rate limiting, CSRF/trusted-origins, secure cookies, session audit hook. - Organizations (clinics) with RBAC (owner/admin/member/viewer) and an extended `patient` permission set; member invitations by email. - Org-scoped patient records mirroring the frontend Patient shape, with CRUD endpoints gated by permission (read/write/delete). - Email helper logs links to the console when SMTP is unset (zero-setup local dev); Dockerfile + docker-compose (db + backend + frontend) with migrations applied on startup and a configurable Postgres host port. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { fromNodeHeaders } from "better-auth/node";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import { auth } from "../auth.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { member } from "../db/schema/auth.js";
|
||||
import { roles } from "../lib/access.js";
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
|
||||
// Validates the Better Auth session cookie and attaches the user + session.
|
||||
export async function requireAuth(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const data = await auth.api.getSession({
|
||||
headers: fromNodeHeaders(req.headers),
|
||||
});
|
||||
if (!data?.session) {
|
||||
throw new HttpError(401, "Authentication required.");
|
||||
}
|
||||
req.user = data.user;
|
||||
req.session = data.session;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Requires an active organization (clinic) and loads the caller's role in it.
|
||||
// Must run after requireAuth.
|
||||
export async function requireOrg(
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const orgId = req.session?.activeOrganizationId;
|
||||
if (!orgId) {
|
||||
throw new HttpError(
|
||||
403,
|
||||
"No active clinic selected. Create or select a clinic first.",
|
||||
);
|
||||
}
|
||||
const [m] = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(
|
||||
and(eq(member.organizationId, orgId), eq(member.userId, req.user!.id)),
|
||||
);
|
||||
if (!m) {
|
||||
throw new HttpError(403, "You are not a member of the active clinic.");
|
||||
}
|
||||
req.organizationId = orgId;
|
||||
req.memberRole = m.role;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
type PatientAction = "read" | "write" | "delete";
|
||||
type PermissionRequest = { patient?: PatientAction[] };
|
||||
|
||||
// Gates a route on a clinic permission, evaluated against the caller's role(s)
|
||||
// using the shared access-control definitions. Must run after requireOrg.
|
||||
export function requirePermission(permission: PermissionRequest) {
|
||||
return async (
|
||||
req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const names = String(req.memberRole ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let allowed = false;
|
||||
for (const name of names) {
|
||||
const role = roles[name as keyof typeof roles];
|
||||
if (role && (await role.authorize(permission)).success) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
throw new HttpError(403, "You don't have permission to do that.");
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
import { HttpError } from "../lib/http-error.js";
|
||||
|
||||
export function notFound(_req: Request, res: Response): void {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
// Express error handler (must take 4 args).
|
||||
export function errorHandler(
|
||||
err: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (err instanceof HttpError) {
|
||||
res.status(err.status).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
if (err instanceof ZodError) {
|
||||
res.status(400).json({ error: "Validation failed", details: err.issues });
|
||||
return;
|
||||
}
|
||||
console.error("[error]", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
Reference in New Issue
Block a user