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:
Claude
2026-06-02 21:27:32 +03:00
parent a39ecbe600
commit 9dabe2f5d2
31 changed files with 8576 additions and 0 deletions
+98
View File
@@ -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);
}
};
}
+27
View File
@@ -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" });
}