Scaffold Homelab Manager foundation

Monorepo (Express+TS+Drizzle/libSQL server, React+Vite+Tabler web) matching
the stack used by ScheduleTaskManager and Sloth Manager. Includes Authentik
OIDC login with local admin/operator/viewer roles (first user becomes admin),
a generalized audit log, encrypted-at-rest storage for future integration API
tokens, the DB schema for all planned modules, and the Tabler-styled app
shell/nav. Also ports the Secrets (expiry tracker) and IP Addresses (IPAM)
modules from Sloth Manager onto the new stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-14 21:57:13 +02:00
commit 6bd2ed52c1
52 files changed
+8103

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
import { Router } from "express";
import { desc } from "drizzle-orm";
import { db } from "../db/client.js";
import { auditLog } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
export const auditLogRouter = Router();
auditLogRouter.use(requireAuth, requireRole("operator"));
auditLogRouter.get("/", async (req, res) => {
const limit = Math.min(Number(req.query.limit ?? 200), 500);
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
res.json({ entries: rows });
});
+108
View File
@@ -0,0 +1,108 @@
import { Router } from "express";
import { isIP } from "node:net";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { ipamEntries } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
export const ipamRouter = Router();
ipamRouter.use(requireAuth);
ipamRouter.get("/", async (_req, res) => {
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
// matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference.
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) });
});
const createInput = z.object({
ipAddress: z.string().refine((v) => isIP(v) !== 0, "Must be a valid IPv4 or IPv6 address"),
label: z.string().max(200).optional(),
vendor: z.string().max(200).optional(),
location: z.string().max(200).optional(),
notes: z.string().max(4000).optional(),
});
const updateInput = createInput.omit({ ipAddress: true }).partial();
ipamRouter.post("/", requireRole("operator"), async (req, res) => {
const parsed = createInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db
.select({ id: ipamEntries.id })
.from(ipamEntries)
.where(eq(ipamEntries.ipAddress, parsed.data.ipAddress))
.limit(1);
if (existing) {
return res.status(409).json({ error: "duplicate_ip" });
}
const [created] = await db.insert(ipamEntries).values(parsed.data).returning();
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "create",
targetType: "ipam_entry",
targetId: created.id,
detail: { ipAddress: created.ipAddress },
});
res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } });
});
ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => {
const id = Number(req.params.id);
const parsed = updateInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
if (!existing) {
return res.status(404).json({ error: "not_found" });
}
const [updated] = await db
.update(ipamEntries)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(ipamEntries.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "update",
targetType: "ipam_entry",
targetId: id,
detail: { ipAddress: updated.ipAddress },
});
res.json({ entry: { ...updated, matchingDnsRecords: [] } });
});
ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
if (!existing) {
return res.status(404).json({ error: "not_found" });
}
await db.delete(ipamEntries).where(eq(ipamEntries.id, id));
await recordAudit({
actor: req.currentUser!,
category: "ipam",
action: "delete",
targetType: "ipam_entry",
targetId: id,
detail: { ipAddress: existing.ipAddress },
});
res.status(204).end();
});
+9
View File
@@ -0,0 +1,9 @@
import { Router } from "express";
import { requireAuth } from "../auth/middleware.js";
export const meRouter = Router();
meRouter.get("/", requireAuth, (req, res) => {
const { id, email, name, role, oidcSub } = req.currentUser!;
res.json({ user: { id, sub: oidcSub, email, name, role } });
});
+99
View File
@@ -0,0 +1,99 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { secrets, secretTypes } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
import { computeSecretStatus } from "../services/secretStatus.js";
export const secretsRouter = Router();
secretsRouter.use(requireAuth);
secretsRouter.get("/", async (_req, res) => {
const rows = await db.select().from(secrets).orderBy(secrets.expiryDate);
res.json({
secrets: rows.map((s) => ({ ...s, ...computeSecretStatus(s.expiryDate, s.warnDays) })),
});
});
const secretInput = z.object({
name: z.string().min(1).max(200),
type: z.enum(secretTypes),
description: z.string().max(2000).optional(),
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
warnDays: z.number().int().min(0).max(3650).default(30),
notes: z.string().max(4000).optional(),
});
secretsRouter.post("/", requireRole("operator"), async (req, res) => {
const parsed = secretInput.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [created] = await db.insert(secrets).values(parsed.data).returning();
await recordAudit({
actor: req.currentUser!,
category: "secret",
action: "create",
targetType: "secret",
targetId: created.id,
detail: { name: created.name },
});
res.status(201).json({ secret: { ...created, ...computeSecretStatus(created.expiryDate, created.warnDays) } });
});
secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => {
const id = Number(req.params.id);
const parsed = secretInput.partial().safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
if (!existing) {
return res.status(404).json({ error: "not_found" });
}
const [updated] = await db
.update(secrets)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(secrets.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "secret",
action: "update",
targetType: "secret",
targetId: id,
detail: { name: updated.name },
});
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } });
});
secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => {
const id = Number(req.params.id);
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
if (!existing) {
return res.status(404).json({ error: "not_found" });
}
await db.delete(secrets).where(eq(secrets.id, id));
await recordAudit({
actor: req.currentUser!,
category: "secret",
action: "delete",
targetType: "secret",
targetId: id,
detail: { name: existing.name },
});
res.status(204).end();
});
+61
View File
@@ -0,0 +1,61 @@
import { Router } from "express";
import { eq, ne, and } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { users, userRoles } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { recordAudit } from "../services/audit.js";
export const usersRouter = Router();
usersRouter.use(requireAuth, requireRole("admin"));
usersRouter.get("/", async (_req, res) => {
const rows = await db.select().from(users).orderBy(users.createdAt);
res.json({ users: rows });
});
const updateRoleSchema = z.object({
role: z.enum(userRoles),
});
usersRouter.patch("/:id/role", async (req, res) => {
const id = Number(req.params.id);
const parsed = updateRoleSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!target) {
return res.status(404).json({ error: "not_found" });
}
if (target.role === "admin" && parsed.data.role !== "admin") {
const otherAdmins = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.role, "admin"), ne(users.id, id)))
.limit(1);
if (otherAdmins.length === 0) {
return res.status(400).json({ error: "last_admin", message: "Cannot remove the only admin." });
}
}
const [updated] = await db
.update(users)
.set({ role: parsed.data.role })
.where(eq(users.id, id))
.returning();
await recordAudit({
actor: req.currentUser!,
category: "user",
action: "update_role",
targetType: "user",
targetId: id,
detail: { from: target.role, to: parsed.data.role, targetLabel: target.name ?? target.email },
});
res.json({ user: updated });
});