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

+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "sqlite",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: `file:${process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite"}`,
},
});
+127
View File
@@ -0,0 +1,127 @@
CREATE TABLE `audit_log` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`actor_user_id` integer,
`actor_label` text,
`category` text NOT NULL,
`action` text NOT NULL,
`target_type` text,
`target_id` text,
`detail` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`actor_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `dns_providers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`provider_type` text NOT NULL,
`name` text NOT NULL,
`credential_id` integer,
`config` text,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`credential_id`) REFERENCES `integration_credentials`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `dns_records_cache` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`provider_id` integer NOT NULL,
`zone` text NOT NULL,
`record_type` text NOT NULL,
`name` text NOT NULL,
`value` text NOT NULL,
`ttl` integer,
`raw` text,
`synced_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`provider_id`) REFERENCES `dns_providers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `integration_credentials` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`encrypted_secret` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `integrations` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`type` text NOT NULL,
`name` text NOT NULL,
`base_url` text NOT NULL,
`credential_id` integer,
`config` text,
`enabled` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`credential_id`) REFERENCES `integration_credentials`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `ipam_entries` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ip_address` text NOT NULL,
`label` text,
`vendor` text,
`location` text,
`notes` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `ipam_entries_ip_address_unique` ON `ipam_entries` (`ip_address`);--> statement-breakpoint
CREATE TABLE `scheduled_tasks` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`server_id` integer NOT NULL,
`schedule_type` text NOT NULL,
`origin` text DEFAULT 'agent' NOT NULL,
`name` text NOT NULL,
`command` text,
`schedule_expression` text,
`source` text,
`enabled` integer DEFAULT true NOT NULL,
`next_run_at` text,
`raw_metadata` text,
`is_stale` integer DEFAULT false NOT NULL,
`first_seen_at` text DEFAULT (current_timestamp) NOT NULL,
`last_seen_at` text DEFAULT (current_timestamp) NOT NULL,
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `secrets` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`type` text DEFAULT 'generic' NOT NULL,
`description` text,
`expiry_date` text NOT NULL,
`warn_days` integer DEFAULT 30 NOT NULL,
`notes` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `servers` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`hostname` text,
`os_type` text DEFAULT 'linux' NOT NULL,
`description` text,
`api_token_hash` text NOT NULL,
`api_token_prefix` text NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`last_seen_at` text
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`oidc_sub` text NOT NULL,
`email` text,
`name` text,
`role` text DEFAULT 'viewer' NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`last_login_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_oidc_sub_unique` ON `users` (`oidc_sub`);
+846
View File
@@ -0,0 +1,846 @@
{
"version": "6",
"dialect": "sqlite",
"id": "1480fe39-d215-4e92-aee3-4de44905a67d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"audit_log": {
"name": "audit_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"actor_user_id": {
"name": "actor_user_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"actor_label": {
"name": "actor_label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"target_type": {
"name": "target_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"target_id": {
"name": "target_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"detail": {
"name": "detail",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"audit_log_actor_user_id_users_id_fk": {
"name": "audit_log_actor_user_id_users_id_fk",
"tableFrom": "audit_log",
"tableTo": "users",
"columnsFrom": [
"actor_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"dns_providers": {
"name": "dns_providers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"provider_type": {
"name": "provider_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"dns_providers_credential_id_integration_credentials_id_fk": {
"name": "dns_providers_credential_id_integration_credentials_id_fk",
"tableFrom": "dns_providers",
"tableTo": "integration_credentials",
"columnsFrom": [
"credential_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"dns_records_cache": {
"name": "dns_records_cache",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"provider_id": {
"name": "provider_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"zone": {
"name": "zone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"record_type": {
"name": "record_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ttl": {
"name": "ttl",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw": {
"name": "raw",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"synced_at": {
"name": "synced_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"dns_records_cache_provider_id_dns_providers_id_fk": {
"name": "dns_records_cache_provider_id_dns_providers_id_fk",
"tableFrom": "dns_records_cache",
"tableTo": "dns_providers",
"columnsFrom": [
"provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"integration_credentials": {
"name": "integration_credentials",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"encrypted_secret": {
"name": "encrypted_secret",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"integrations": {
"name": "integrations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"base_url": {
"name": "base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"credential_id": {
"name": "credential_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"config": {
"name": "config",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"integrations_credential_id_integration_credentials_id_fk": {
"name": "integrations_credential_id_integration_credentials_id_fk",
"tableFrom": "integrations",
"tableTo": "integration_credentials",
"columnsFrom": [
"credential_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ipam_entries": {
"name": "ipam_entries",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vendor": {
"name": "vendor",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"location": {
"name": "location",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {
"ipam_entries_ip_address_unique": {
"name": "ipam_entries_ip_address_unique",
"columns": [
"ip_address"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"scheduled_tasks": {
"name": "scheduled_tasks",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_type": {
"name": "schedule_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"origin": {
"name": "origin",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'agent'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"schedule_expression": {
"name": "schedule_expression",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"next_run_at": {
"name": "next_run_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"raw_metadata": {
"name": "raw_metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_stale": {
"name": "is_stale",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"first_seen_at": {
"name": "first_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {
"scheduled_tasks_server_id_servers_id_fk": {
"name": "scheduled_tasks_server_id_servers_id_fk",
"tableFrom": "scheduled_tasks",
"tableTo": "servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"secrets": {
"name": "secrets",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'generic'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"warn_days": {
"name": "warn_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"servers": {
"name": "servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hostname": {
"name": "hostname",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"os_type": {
"name": "os_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'linux'"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_token_hash": {
"name": "api_token_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_token_prefix": {
"name": "api_token_prefix",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"oidc_sub": {
"name": "oidc_sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'viewer'"
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(current_timestamp)"
},
"last_login_at": {
"name": "last_login_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"users_oidc_sub_unique": {
"name": "users_oidc_sub_unique",
"columns": [
"oidc_sub"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1789415495200,
"tag": "0000_tricky_nocturne",
"breakpoints": true
}
]
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "server",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
"@libsql/client": "^0.14.0",
"drizzle-orm": "^0.45.2",
"express": "^4.21.2",
"express-session": "^1.18.1",
"openid-client": "^6.1.7",
"session-file-store": "^1.5.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.1",
"@types/node": "^22.10.5",
"@types/session-file-store": "^1.2.5",
"drizzle-kit": "^0.31.10",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}
+44
View File
@@ -0,0 +1,44 @@
import type { Request, Response, NextFunction } from "express";
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { users, type UserRole } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect;
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
currentUser?: CurrentUser;
}
}
}
/** Requires a valid session AND a matching local user row; attaches req.currentUser. */
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const sessionUser = req.session.user;
if (!sessionUser) {
return res.status(401).json({ error: "unauthorized" });
}
const [user] = await db.select().from(users).where(eq(users.oidcSub, sessionUser.sub)).limit(1);
if (!user) {
return res.status(401).json({ error: "unauthorized" });
}
req.currentUser = user;
next();
}
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
/** Must run after requireAuth. Rejects unless the current user's role is >= minRole. */
export function requireRole(minRole: UserRole) {
return (req: Request, res: Response, next: NextFunction) => {
const user = req.currentUser;
if (!user || roleRank[user.role] < roleRank[minRole]) {
return res.status(403).json({ error: "forbidden" });
}
next();
};
}
+15
View File
@@ -0,0 +1,15 @@
import * as client from "openid-client";
import { env } from "../env.js";
let configPromise: Promise<client.Configuration> | null = null;
export function getOidcConfig(): Promise<client.Configuration> {
if (!configPromise) {
configPromise = client.discovery(
new URL(env.authentik.issuerUrl),
env.authentik.clientId,
env.authentik.clientSecret,
);
}
return configPromise;
}
+100
View File
@@ -0,0 +1,100 @@
import { Router } from "express";
import * as client from "openid-client";
import { getOidcConfig } from "./oidc.js";
import { upsertUserFromLogin } from "./users.js";
import { env } from "../env.js";
export const authRouter = Router();
authRouter.get("/login", async (req, res, next) => {
try {
const config = await getOidcConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
req.session.pendingAuth = { codeVerifier, state };
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
});
req.session.save((err) => {
if (err) return next(err);
res.redirect(authUrl.href);
});
} catch (err) {
next(err);
}
});
authRouter.get("/callback", async (req, res, next) => {
try {
const pending = req.session.pendingAuth;
if (!pending) {
return res.status(400).send("Login session expired. Please try signing in again.");
}
const config = await getOidcConfig();
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: pending.codeVerifier,
expectedState: pending.state,
});
const claims = tokens.claims();
if (!claims?.sub) {
return res.status(400).send("Identity provider did not return a valid identity.");
}
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
try {
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
email = userinfo.email ?? email;
name = userinfo.name ?? userinfo.preferred_username ?? name;
} catch {
// fall back to ID token claims already captured above
}
await upsertUserFromLogin({ sub: claims.sub, email, name });
delete req.session.pendingAuth;
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
req.session.save((err) => {
if (err) return next(err);
res.redirect("/");
});
} catch (err) {
next(err);
}
});
authRouter.get("/logout", async (req, res, next) => {
const idToken = req.session.user?.idToken;
try {
const config = await getOidcConfig();
let endSessionUrl: URL | undefined;
try {
endSessionUrl = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: env.appBaseUrl,
...(idToken ? { id_token_hint: idToken } : {}),
});
} catch {
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
}
req.session.destroy((err) => {
if (err) return next(err);
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
});
} catch (err) {
next(err);
}
});
+45
View File
@@ -0,0 +1,45 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
/**
* Called on every successful OIDC login. The very first user ever to sign in
* becomes admin; everyone after defaults to viewer until an admin promotes
* them from the Users page.
*/
export async function upsertUserFromLogin(params: {
sub: string;
email?: string;
name?: string;
}) {
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
const now = new Date().toISOString();
if (existing) {
const [updated] = await db
.update(users)
.set({
email: params.email ?? existing.email,
name: params.name ?? existing.name,
lastLoginAt: now,
})
.where(eq(users.id, existing.id))
.returning();
return updated;
}
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
const role = anyUser.length === 0 ? ("admin" as const) : ("viewer" as const);
const [created] = await db
.insert(users)
.values({
oidcSub: params.sub,
email: params.email,
name: params.name,
role,
lastLoginAt: now,
})
.returning();
return created;
}
+37
View File
@@ -0,0 +1,37 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { env } from "./env.js";
const ALGO = "aes-256-gcm";
function getKey(): Buffer {
if (!env.credentialsEncryptionEnabled) {
throw new Error(
"CREDENTIALS_ENCRYPTION_KEY is not configured (must be a 64-character hex string)",
);
}
return Buffer.from(env.credentialsEncryptionKey, "hex");
}
/** Encrypts a plaintext secret for storage. Returns "iv:authTag:ciphertext" as hex. */
export function encryptSecret(plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGO, getKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv.toString("hex"), authTag.toString("hex"), ciphertext.toString("hex")].join(":");
}
/** Reverses encryptSecret(). Throws if the key changed or the data was tampered with. */
export function decryptSecret(stored: string): string {
const [ivHex, authTagHex, ciphertextHex] = stored.split(":");
if (!ivHex || !authTagHex || !ciphertextHex) {
throw new Error("Malformed encrypted credential");
}
const decipher = createDecipheriv(ALGO, getKey(), Buffer.from(ivHex, "hex"));
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextHex, "hex")),
decipher.final(),
]);
return plaintext.toString("utf8");
}
+15
View File
@@ -0,0 +1,15 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { resolveDataPath } from "../paths.js";
import * as schema from "./schema.js";
const dbPath = resolveDataPath(process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite");
mkdirSync(dirname(dbPath), { recursive: true });
const client = createClient({ url: `file:${dbPath}` });
await client.execute("PRAGMA foreign_keys = ON;");
export const db = drizzle(client, { schema });
export { client };
+17
View File
@@ -0,0 +1,17 @@
import { migrate } from "drizzle-orm/libsql/migrator";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { db, client } from "./client.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
export async function runMigrations() {
await migrate(db, { migrationsFolder: join(__dirname, "..", "..", "drizzle") });
}
// Allow running directly via `npm run db:migrate`
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await runMigrations();
client.close();
console.log("Migrations applied.");
}
+202
View File
@@ -0,0 +1,202 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
// ─── Users & roles ──────────────────────────────────────────────────────────
export const userRoles = ["admin", "operator", "viewer"] as const;
export type UserRole = (typeof userRoles)[number];
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
oidcSub: text("oidc_sub").notNull().unique(),
email: text("email"),
name: text("name"),
role: text("role").$type<UserRole>().notNull().default("viewer"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
lastLoginAt: text("last_login_at"),
});
// ─── Audit log ──────────────────────────────────────────────────────────────
export const auditLog = sqliteTable("audit_log", {
id: integer("id").primaryKey({ autoIncrement: true }),
actorUserId: integer("actor_user_id").references(() => users.id, { onDelete: "set null" }),
actorLabel: text("actor_label"), // denormalized name/email snapshot, survives user deletion
category: text("category").notNull(), // 'secret' | 'ipam' | 'dns' | 'user' | 'integration' | 'task' | ...
action: text("action").notNull(), // 'create' | 'update' | 'delete' | 'start' | 'stop' | ...
targetType: text("target_type"),
targetId: text("target_id"),
detail: text("detail"), // JSON-encoded free-form context
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Settings (key/value) ───────────────────────────────────────────────────
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Secrets expiry tracker (ported from Sloth Manager) ────────────────────
export const secretTypes = ["api_token", "ssl_certificate", "password", "generic"] as const;
export type SecretType = (typeof secretTypes)[number];
export const secrets = sqliteTable("secrets", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").$type<SecretType>().notNull().default("generic"),
description: text("description"),
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
warnDays: integer("warn_days").notNull().default(30),
notes: text("notes"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── IPAM (ported from Sloth Manager) ───────────────────────────────────────
export const ipamEntries = sqliteTable("ipam_entries", {
id: integer("id").primaryKey({ autoIncrement: true }),
ipAddress: text("ip_address").notNull().unique(),
label: text("label"),
vendor: text("vendor"),
location: text("location"),
notes: text("notes"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Integration credentials (encrypted API tokens) ─────────────────────────
export const integrationCredentials = sqliteTable("integration_credentials", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
encryptedSecret: text("encrypted_secret").notNull(), // AES-256-GCM, see src/crypto.ts
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── DNS providers + record cache (ported from Sloth Manager) ──────────────
export const dnsProviderTypes = [
"cloudflare",
"loopia",
"pihole",
"azure",
"cpanel",
"technitium",
] as const;
export type DnsProviderType = (typeof dnsProviderTypes)[number];
export const dnsProviders = sqliteTable("dns_providers", {
id: integer("id").primaryKey({ autoIncrement: true }),
providerType: text("provider_type").$type<DnsProviderType>().notNull(),
name: text("name").notNull(),
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
onDelete: "set null",
}),
config: text("config"), // JSON: non-secret provider config (base URL, zone list, etc.)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const dnsRecordsCache = sqliteTable("dns_records_cache", {
id: integer("id").primaryKey({ autoIncrement: true }),
providerId: integer("provider_id")
.notNull()
.references(() => dnsProviders.id, { onDelete: "cascade" }),
zone: text("zone").notNull(),
recordType: text("record_type").notNull(), // A, AAAA, CNAME, TXT, MX, ...
name: text("name").notNull(),
value: text("value").notNull(),
ttl: integer("ttl"),
raw: text("raw"), // JSON: original provider payload for this record
syncedAt: text("synced_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Servers & scheduled tasks (ported from Schedule Task Manager) ─────────
export const servers = sqliteTable("servers", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
hostname: text("hostname"),
osType: text("os_type").notNull().default("linux"),
description: text("description"),
apiTokenHash: text("api_token_hash").notNull(),
apiTokenPrefix: text("api_token_prefix").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at"),
});
export const scheduledTasks = sqliteTable("scheduled_tasks", {
id: integer("id").primaryKey({ autoIncrement: true }),
serverId: integer("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
name: text("name").notNull(),
command: text("command"),
scheduleExpression: text("schedule_expression"),
source: text("source"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
nextRunAt: text("next_run_at"),
rawMetadata: text("raw_metadata"),
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
firstSeenAt: text("first_seen_at")
.notNull()
.default(sql`(current_timestamp)`),
lastSeenAt: text("last_seen_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─
export const integrationTypes = [
"proxmox",
"synology",
"semaphore",
"tailscale",
"gitea",
"dockhand",
] as const;
export type IntegrationType = (typeof integrationTypes)[number];
export const integrations = sqliteTable("integrations", {
id: integer("id").primaryKey({ autoIncrement: true }),
type: text("type").$type<IntegrationType>().notNull(),
name: text("name").notNull(),
baseUrl: text("base_url").notNull(),
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
onDelete: "set null",
}),
config: text("config"), // JSON: per-type non-secret config (tailnet name, node name, etc.)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
+39
View File
@@ -0,0 +1,39 @@
export const env = {
port: Number(process.env.PORT ?? 3000),
nodeEnv: process.env.NODE_ENV ?? "development",
appBaseUrl: process.env.APP_BASE_URL ?? "http://localhost:3000",
sessionSecret: process.env.SESSION_SECRET ?? "dev-insecure-session-secret-change-me",
sessionDir: process.env.SESSION_DIR ?? "../data/sessions",
databasePath: process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite",
credentialsEncryptionKey: process.env.CREDENTIALS_ENCRYPTION_KEY ?? "",
authentik: {
issuerUrl: process.env.AUTHENTIK_ISSUER_URL ?? "",
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
},
gotify: {
url: process.env.GOTIFY_URL ?? "",
token: process.env.GOTIFY_TOKEN ?? "",
},
get authEnabled() {
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
},
get credentialsEncryptionEnabled() {
return this.credentialsEncryptionKey.length === 64;
},
};
export function warnIfAuthNotConfigured() {
if (!env.authEnabled) {
console.warn(
"AUTHENTIK_ISSUER_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET are not fully set. " +
"The app will boot, but /auth/login and /auth/logout will fail until they are configured.",
);
}
if (!env.credentialsEncryptionEnabled) {
console.warn(
"CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " +
"Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured.",
);
}
}
+73
View File
@@ -0,0 +1,73 @@
import express from "express";
import session from "express-session";
import FileStoreFactory from "session-file-store";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { env, warnIfAuthNotConfigured } from "./env.js";
import { resolveDataPath } from "./paths.js";
import { runMigrations } from "./db/migrate.js";
import { authRouter } from "./auth/router.js";
import { meRouter } from "./routes/me.js";
import { usersRouter } from "./routes/users.js";
import { auditLogRouter } from "./routes/auditLog.js";
import { secretsRouter } from "./routes/secrets.js";
import { ipamRouter } from "./routes/ipam.js";
warnIfAuthNotConfigured();
await runMigrations();
const __dirname = dirname(fileURLToPath(import.meta.url));
const webDist = join(__dirname, "..", "..", "web", "dist");
const agentDir = join(__dirname, "..", "..", "agent");
const FileStore = FileStoreFactory(session);
const sessionDir = resolveDataPath(env.sessionDir);
mkdirSync(sessionDir, { recursive: true });
const app = express();
app.set("trust proxy", 1);
app.use(express.json());
app.use(
session({
store: new FileStore({ path: sessionDir, logFn: () => {} }),
secret: env.sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "lax",
secure: env.nodeEnv === "production",
maxAge: 7 * 24 * 60 * 60 * 1000,
},
}),
);
app.get("/health", (_req, res) => res.json({ ok: true }));
// Publicly readable so `curl .../agent/linux/install.sh | bash` works from a
// freshly provisioned server with no prior session. Contains no secrets.
if (existsSync(agentDir)) {
app.use("/agent", express.static(agentDir));
}
app.use("/auth", authRouter);
app.use("/api/me", meRouter);
app.use("/api/users", usersRouter);
app.use("/api/audit-log", auditLogRouter);
app.use("/api/secrets", secretsRouter);
app.use("/api/ipam", ipamRouter);
if (existsSync(webDist)) {
app.use(express.static(webDist));
app.get("*", (_req, res) => res.sendFile(join(webDist, "index.html")));
}
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err);
res.status(500).json({ error: "internal_error" });
});
app.listen(env.port, () => {
console.log(`homelab-manager listening on port ${env.port}`);
});
+10
View File
@@ -0,0 +1,10 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve, isAbsolute } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const serverRoot = resolve(__dirname, ".."); // server/
/** Resolves a config path relative to the server package root, regardless of process.cwd(). */
export function resolveDataPath(path: string): string {
return isAbsolute(path) ? path : resolve(serverRoot, path);
}
+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 });
});
+24
View File
@@ -0,0 +1,24 @@
import { db } from "../db/client.js";
import { auditLog, users } from "../db/schema.js";
type CurrentUser = typeof users.$inferSelect;
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */
export async function recordAudit(params: {
actor: CurrentUser;
category: string;
action: string;
targetType?: string;
targetId?: string | number;
detail?: unknown;
}) {
await db.insert(auditLog).values({
actorUserId: params.actor.id,
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
category: params.category,
action: params.action,
targetType: params.targetType,
targetId: params.targetId !== undefined ? String(params.targetId) : undefined,
detail: params.detail !== undefined ? JSON.stringify(params.detail) : undefined,
});
}
+19
View File
@@ -0,0 +1,19 @@
export type SecretStatus = "ok" | "expiring" | "expired";
/** Computes status + days-left for a secret, matching Sloth Manager's original semantics. */
export function computeSecretStatus(
expiryDate: string,
warnDays: number,
now: Date = new Date(),
): { status: SecretStatus; daysLeft: number } {
const expiry = new Date(`${expiryDate}T00:00:00Z`);
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const daysLeft = Math.round((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
let status: SecretStatus;
if (daysLeft < 0) status = "expired";
else if (daysLeft <= warnDays) status = "expiring";
else status = "ok";
return { status, daysLeft };
}
+16
View File
@@ -0,0 +1,16 @@
import "express-session";
declare module "express-session" {
interface SessionData {
user?: {
sub: string;
email?: string;
name?: string;
idToken?: string;
};
pendingAuth?: {
codeVerifier: string;
state: string;
};
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"sourceMap": false
},
"include": ["src"]
}