Replace next-auth with Better Auth, migrate DB columns to camelCase

- Replace next-auth v5 beta with better-auth v1.6.2 (stable releases)
- Add multi-provider OAuth support with admin UI configuration
- New oauthProviders table with encrypted secrets (AES-256-GCM)
- Env var bootstrap (OAUTH_*) syncs to DB, UI-created providers fully editable
- OAuth provider REST API: GET/POST/PUT/DELETE /api/v1/oauth-providers
- Settings page "Authentication Providers" section for admin management
- Account linking uses new accounts table (multi-provider per user)
- Username plugin for credentials sign-in (replaces email@localhost pattern)
- bcrypt password compatibility (existing hashes work)
- Database-backed sessions via Kysely adapter (bun:sqlite direct)
- Configurable rate limiting via AUTH_RATE_LIMIT_* env vars
- All DB columns migrated from snake_case to camelCase
- All TypeScript types/models migrated to camelCase properties
- Removed casing: "snake_case" from Drizzle config
- Callback URL format: {baseUrl}/api/auth/oauth2/callback/{providerId}
- package-lock.json removed and gitignored (using bun.lock)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fuomag9
2026-04-12 21:11:48 +02:00
parent eb78b64c2f
commit 3a16d6e9b1
100 changed files with 3390 additions and 14495 deletions

6
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient, usernameClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
plugins: [usernameClient(), genericOAuthClient()],
});

152
src/lib/auth-server.ts Normal file
View File

@@ -0,0 +1,152 @@
import { betterAuth } from "better-auth";
import { genericOAuth, username } from "better-auth/plugins";
import { randomUUID } from "node:crypto";
import db, { sqlite } from "./db";
import * as schema from "./db/schema";
import { eq } from "drizzle-orm";
import { config } from "./config";
import { decryptSecret } from "./secret";
import type { OAuthProvider } from "./models/oauth-providers";
import type { GenericOAuthConfig } from "better-auth/plugins";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cachedAuth: any = null;
let cachedProviders: GenericOAuthConfig[] | null = null;
function mapOAuthProvider(p: OAuthProvider): GenericOAuthConfig {
const cfg: GenericOAuthConfig = {
providerId: p.id,
clientId: p.clientId,
clientSecret: p.clientSecret,
scopes: p.scopes ? p.scopes.split(/[\s,]+/).filter(Boolean) : undefined,
pkce: true,
};
if (p.authorizationUrl) cfg.authorizationUrl = p.authorizationUrl;
if (p.tokenUrl) cfg.tokenUrl = p.tokenUrl;
if (p.userinfoUrl) cfg.userInfoUrl = p.userinfoUrl;
if (p.issuer) {
cfg.issuer = p.issuer;
// Only use discovery when explicit URLs are not provided
if (!p.authorizationUrl && !p.tokenUrl) {
cfg.discoveryUrl = p.issuer.replace(/\/$/, "") + "/.well-known/openid-configuration";
}
}
return cfg;
}
/** Whether provider load succeeded at least once */
let providersLoadedSuccessfully = false;
function loadProvidersSync(): GenericOAuthConfig[] {
// If we have a successful cache, use it
if (cachedProviders !== null && providersLoadedSuccessfully) return cachedProviders;
// If cache is empty from a failed attempt, retry on every call until it succeeds
try {
const rows = db.select().from(schema.oauthProviders)
.where(eq(schema.oauthProviders.enabled, true)).all();
const providers: OAuthProvider[] = rows.map((row) => ({
id: row.id,
name: row.name,
type: row.type,
clientId: decryptSecret(row.clientId),
clientSecret: decryptSecret(row.clientSecret),
issuer: row.issuer,
authorizationUrl: row.authorizationUrl,
tokenUrl: row.tokenUrl,
userinfoUrl: row.userinfoUrl,
scopes: row.scopes,
autoLink: row.autoLink,
enabled: row.enabled,
source: row.source,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
cachedProviders = providers.map(mapOAuthProvider);
providersLoadedSuccessfully = true;
} catch (e) {
// DB not ready yet — start with empty, will retry on next getAuth() call
if (!cachedProviders) cachedProviders = [];
console.warn("[auth-server] Failed to load OAuth providers (will retry):", e);
}
return cachedProviders;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function createAuth(): any {
const oauthConfigs = loadProvidersSync();
return betterAuth({
database: sqlite,
secret: config.sessionSecret,
baseURL: config.baseUrl,
basePath: "/api/auth",
trustedOrigins: [config.baseUrl],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
advanced: {
database: {
generateId: "serial",
},
} as any,
rateLimit: {
enabled: process.env.AUTH_RATE_LIMIT_ENABLED !== "false",
window: Number(process.env.AUTH_RATE_LIMIT_WINDOW ?? 10),
max: Number(process.env.AUTH_RATE_LIMIT_MAX ?? 200),
},
user: {
modelName: "users",
fields: {
image: "avatarUrl",
},
additionalFields: {
role: { type: "string", defaultValue: "user", input: false },
status: { type: "string", defaultValue: "active", input: false },
provider: { type: "string", defaultValue: "", input: false },
subject: { type: "string", defaultValue: "", input: false },
},
},
session: {
modelName: "sessions",
expiresIn: 7 * 24 * 60 * 60,
cookieCache: { enabled: false },
},
account: { modelName: "accounts" },
verification: { modelName: "verifications" },
emailAndPassword: {
enabled: true,
password: {
async hash(password: string) {
const bcrypt = await import("bcryptjs");
return bcrypt.default.hashSync(password, 12);
},
async verify({ hash, password }: { hash: string; password: string }) {
const bcrypt = await import("bcryptjs");
return bcrypt.default.compareSync(password, hash);
},
},
},
plugins: [
username(),
genericOAuth({ config: oauthConfigs }),
],
});
}
export function getAuth(): ReturnType<typeof betterAuth> {
// Rebuild if providers failed to load initially and are now available
if (cachedAuth && !providersLoadedSuccessfully) {
cachedProviders = null;
cachedAuth = null;
}
if (!cachedAuth) {
cachedAuth = createAuth();
}
return cachedAuth;
}
export function invalidateProviderCache(): void {
cachedProviders = null;
providersLoadedSuccessfully = false;
cachedAuth = null;
}

View File

@@ -1,431 +1,92 @@
import NextAuth, { type DefaultSession } from "next-auth";
import { type NextRequest, NextResponse } from "next/server";
import Credentials from "next-auth/providers/credentials";
import type { OAuthConfig } from "next-auth/providers";
import bcrypt from "bcryptjs";
import db from "./db";
import { config } from "./config";
import { findUserByProviderSubject, createUser, getUserById } from "./models/user";
import { createAuditEvent } from "./models/audit";
import { decideLinkingStrategy, createLinkingToken, storeLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
import { getAuth } from "./auth-server";
import { getUserById } from "./models/user";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
provider?: string;
} & DefaultSession["user"];
export type Session = {
user: {
id: string;
email: string;
name: string | null;
role: string;
provider?: string;
image?: string | null;
};
};
/**
* Get the current session, optionally from a specific request.
*
* - `auth()` — uses `headers()` from next/headers (server components, route handlers)
* - `auth(req)` — uses request headers (middleware)
*
* Returns `Session | null`. The user's role is always fetched fresh from the database
* so that role changes (e.g. demotion) take effect immediately.
*/
export async function auth(req?: NextRequest): Promise<Session | null> {
const hdrs = req
? req.headers
: (await import("next/headers")).headers();
// headers() in Next.js 15+ returns a Promise
const resolvedHeaders = hdrs instanceof Promise ? await hdrs : hdrs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let betterAuthSession: any = null;
try {
betterAuthSession = await getAuth().api.getSession({
headers: resolvedHeaders,
});
} catch {
return null;
}
interface User {
if (!betterAuthSession?.user) {
return null;
}
const baUser = betterAuthSession.user as {
id: string | number;
name?: string | null;
email: string;
image?: string | null;
role?: string;
provider?: string;
}
}
status?: string;
avatarUrl?: string | null;
subject?: string;
};
const userId = typeof baUser.id === "string" ? Number(baUser.id) : baUser.id;
// Credentials provider that checks against hashed passwords in the database
function createCredentialsProvider() {
return Credentials({
id: "credentials",
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
const username = credentials?.username ? String(credentials.username).trim() : "";
const password = credentials?.password ? String(credentials.password) : "";
if (!username || !password) {
return null;
}
// Look up user in database by email (constructed from username)
const email = `${username}@localhost`;
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.email, email)
});
if (!user || user.status !== "active" || !user.passwordHash) {
return null;
}
// Verify password against hashed password in database
const isValidPassword = bcrypt.compareSync(password, user.passwordHash);
if (!isValidPassword) {
return null;
}
return {
id: user.id.toString(),
name: user.name ?? username,
email: user.email,
role: user.role
};
}
});
}
const credentialsProvider = createCredentialsProvider();
// Create OAuth providers based on configuration
function createOAuthProviders(): OAuthConfig<Record<string, unknown>>[] {
const providers: OAuthConfig<Record<string, unknown>>[] = [];
if (
config.oauth.enabled &&
config.oauth.clientId &&
config.oauth.clientSecret
) {
const oauthProvider: OAuthConfig<Record<string, unknown>> = {
id: "oauth2",
name: config.oauth.providerName,
type: "oidc",
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
issuer: config.oauth.issuer ?? undefined,
authorization: config.oauth.authorizationUrl ?? undefined,
token: config.oauth.tokenUrl ?? undefined,
userinfo: config.oauth.userinfoUrl ?? undefined,
// PKCE is the default for OIDC; state is added as defence-in-depth
checks: ["pkce", "state"],
profile(profile) {
const sub = typeof profile.sub === "string" ? profile.sub : undefined;
const id = typeof profile.id === "string" ? profile.id : undefined;
const name = typeof profile.name === "string" ? profile.name : undefined;
const preferredUsername =
typeof profile.preferred_username === "string" ? profile.preferred_username : undefined;
const email = typeof profile.email === "string" ? profile.email : undefined;
const picture = typeof profile.picture === "string" ? profile.picture : null;
const avatarUrl = typeof profile.avatar_url === "string" ? profile.avatar_url : null;
return {
id: sub ?? id,
name: name ?? preferredUsername ?? email,
email,
image: picture ?? avatarUrl,
};
},
};
providers.push(oauthProvider);
// Always fetch current role/status from database to reflect changes immediately
const currentUser = await getUserById(userId);
if (!currentUser || currentUser.status !== "active") {
return null;
}
return providers;
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [credentialsProvider, ...createOAuthProviders()],
session: {
strategy: "jwt",
maxAge: 7 * 24 * 60 * 60, // 7 days
},
pages: {
signIn: "/login",
},
callbacks: {
async signIn({ user, account }) {
// Credentials provider - handled by authorize function
if (account?.provider === "credentials") {
return true;
}
// OAuth provider sign-in
if (!account || !user.email) {
return false;
}
try {
// Check if this is an OAuth linking attempt by checking the database
const { pendingOAuthLinks } = await import("./db/schema");
const { eq } = await import("drizzle-orm");
const { nowIso } = await import("./db");
// Find ALL non-expired pending links for this provider
const allPendingLinks = await db.query.pendingOAuthLinks.findMany({
where: (table, operators) =>
operators.and(
operators.eq(table.provider, account.provider),
operators.gt(table.expiresAt, nowIso())
)
});
// Security: Match by userId to prevent race condition where User B could
// overwrite User A's pending link. We verify by checking which user exists.
let pendingLink = null;
if (allPendingLinks.length === 1) {
// Common case: only one user is linking this provider right now
pendingLink = allPendingLinks[0];
} else if (allPendingLinks.length > 1) {
// Race condition detected: multiple users linking same provider
// This shouldn't happen with unique index, but handle gracefully
// Find the user whose email matches their stored email
for (const link of allPendingLinks) {
const existingUser = await getUserById(link.userId);
if (existingUser && existingUser.email === link.userEmail) {
pendingLink = link;
break;
}
}
}
if (pendingLink) {
try {
const userId = pendingLink.userId;
const existingUser = await getUserById(userId);
if (existingUser) {
// Security: Validate OAuth email matches the authenticated user's stored email
// This prevents users from linking arbitrary OAuth accounts to their credentials account
if (user.email && (
existingUser.email !== pendingLink.userEmail ||
user.email.toLowerCase() !== pendingLink.userEmail.toLowerCase()
)) {
console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.email} (OAuth provider returned ${user.email})`);
// Clean up the pending link
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
// Audit log for security event
await createAuditEvent({
userId: existingUser.id,
action: "oauth_link_rejected",
entityType: "user",
entityId: existingUser.id,
summary: `OAuth linking rejected: email mismatch`,
data: JSON.stringify({
provider: account.provider,
expectedEmail: pendingLink.userEmail,
actualEmail: existingUser.email
})
});
return false;
}
// User is already authenticated - auto-link
const linked = await linkOAuthAuthenticated(
userId,
account.provider,
account.providerAccountId,
user.image
);
if (linked) {
// Reload user from database to get updated data
const updatedUser = await getUserById(userId);
if (updatedUser) {
user.id = updatedUser.id.toString();
user.role = updatedUser.role;
user.provider = updatedUser.provider;
user.email = updatedUser.email;
user.name = updatedUser.name;
// Delete the pending link
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
// Audit log
await createAuditEvent({
userId: updatedUser.id,
action: "account_linked",
entityType: "user",
entityId: updatedUser.id,
summary: `OAuth account linked via ${config.oauth.providerName}`,
data: JSON.stringify({ provider: account.provider, email: user.email })
});
return true;
}
}
}
} catch (e) {
console.error("Error processing pending link:", e);
}
}
// Check if OAuth account already exists
const existingOAuthUser = await findUserByProviderSubject(
account.provider,
account.providerAccountId
);
if (existingOAuthUser) {
// Existing OAuth user - update user object and allow sign-in
user.id = existingOAuthUser.id.toString();
user.role = existingOAuthUser.role;
user.provider = existingOAuthUser.provider;
// Audit log
await createAuditEvent({
userId: existingOAuthUser.id,
action: "oauth_signin",
entityType: "user",
entityId: existingOAuthUser.id,
summary: `${existingOAuthUser.name || existingOAuthUser.email || "User"} signed in via ${config.oauth.providerName}`,
data: JSON.stringify({ provider: account.provider })
});
return true;
}
// Determine linking strategy
const decision = await decideLinkingStrategy(
account.provider,
account.providerAccountId,
user.email
);
if (decision.action === "auto_link" && decision.userId) {
// Auto-link OAuth to existing account without password
const linked = await autoLinkOAuth(
decision.userId,
account.provider,
account.providerAccountId,
user.image
);
if (linked) {
const linkedUser = await getUserById(decision.userId);
if (linkedUser) {
user.id = linkedUser.id.toString();
user.role = linkedUser.role;
user.provider = linkedUser.provider;
// Audit log
await createAuditEvent({
userId: linkedUser.id,
action: "account_linked",
entityType: "user",
entityId: linkedUser.id,
summary: `OAuth account auto-linked via ${config.oauth.providerName}`,
data: JSON.stringify({ provider: account.provider, email: user.email })
});
return true;
}
}
}
if (decision.action === "require_manual_link" && decision.userId) {
// Email collision - require manual linking with password verification
const linkingToken = await createLinkingToken(
decision.userId,
account.provider,
account.providerAccountId,
user.email
);
const linkingId = await storeLinkingToken(linkingToken);
// Redirect to link-account page with opaque ID (not the JWT)
throw new Error(`LINKING_REQUIRED:${linkingId}`);
}
// New OAuth user - create account
const newUser = await createUser({
email: user.email,
name: user.name,
provider: account.provider,
subject: account.providerAccountId,
avatar_url: user.image
});
user.id = newUser.id.toString();
user.role = newUser.role;
user.provider = newUser.provider;
// Audit log
await createAuditEvent({
userId: newUser.id,
action: "oauth_signup",
entityType: "user",
entityId: newUser.id,
summary: `New user ${user.name || user.email || ""} created via ${config.oauth.providerName}`,
data: JSON.stringify({ provider: account.provider, email: user.email })
});
return true;
} catch (error) {
// LINKING_REQUIRED is expected flow — rethrow so NextAuth can redirect
if (error instanceof Error && error.message.startsWith("LINKING_REQUIRED:")) {
throw error;
}
console.error("OAuth sign-in error:", error);
// Audit log for failed OAuth attempts
try {
await createAuditEvent({
userId: null,
action: "oauth_signin_failed",
entityType: "user",
entityId: null,
summary: `OAuth sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
data: JSON.stringify({
provider: account?.provider,
email: user?.email,
error: error instanceof Error ? error.message : String(error)
})
});
} catch (auditError) {
console.error("Failed to create audit log for OAuth error:", auditError);
}
return false;
}
return {
user: {
id: String(currentUser.id),
email: currentUser.email,
name: currentUser.name,
role: currentUser.role,
provider: currentUser.provider || baUser.provider,
image: currentUser.avatarUrl ?? (baUser.avatarUrl as string | null | undefined) ?? null,
},
async jwt({ token, user, account }) {
// On sign in, add user info to token
if (user) {
token.id = user.id;
token.email = user.email;
token.role = user.role ?? "user";
token.provider = account?.provider ?? user.provider ?? "credentials";
token.image = user.image;
}
return token;
},
async session({ session, token }) {
// Add user info from token to session
if (session.user && token.id) {
session.user.id = token.id as string;
session.user.provider = token.provider as string;
// Always fetch current role from database to reflect
// role changes (e.g. demotion) without waiting for JWT expiry
const userId = Number(token.id);
const currentUser = await getUserById(userId);
if (currentUser) {
session.user.role = currentUser.role;
session.user.image = currentUser.avatar_url ?? (token.image as string | null | undefined);
} else {
// User deleted from DB — deny access by clearing session
session.user.role = token.role as string;
session.user.image = token.image as string | null | undefined;
}
}
return session;
},
},
secret: config.sessionSecret,
// Only trust Host header when explicitly opted in or when NEXTAUTH_URL
// is set (operator has declared the canonical URL, so Host validation is moot).
trustHost: !!process.env.NEXTAUTH_TRUST_HOST || !!process.env.NEXTAUTH_URL,
basePath: "/api/auth",
});
/**
* Helper function to get the current session on the server.
*/
export async function getSession() {
return await auth();
};
}
/**
* Helper function to require authentication, throwing if not authenticated.
* Alias for auth() — get the current session on the server.
*/
export async function requireUser() {
export async function getSession(): Promise<Session | null> {
return auth();
}
/**
* Require authentication. Redirects to /login if not authenticated.
*/
export async function requireUser(): Promise<Session> {
const session = await auth();
if (!session?.user) {
const { redirect } = await import("next/navigation");
@@ -435,7 +96,10 @@ export async function requireUser() {
return session;
}
export async function requireAdmin() {
/**
* Require admin privileges. Throws if not authenticated or not admin.
*/
export async function requireAdmin(): Promise<Session> {
const session = await requireUser();
if (session.user.role !== "admin") {
throw new Error("Administrator privileges required");

View File

@@ -21,10 +21,10 @@ export function normalizeFingerprint(fp: string): string {
* Defined here to avoid importing from models (which pulls in db.ts).
*/
export type MtlsAccessRuleLike = {
path_pattern: string;
allowed_role_ids: number[];
allowed_cert_ids: number[];
deny_all: boolean;
pathPattern: string;
allowedRoleIds: number[];
allowedCertIds: number[];
denyAll: boolean;
};
/**
@@ -177,14 +177,14 @@ export function resolveAllowedFingerprints(
): Set<string> {
const allowed = new Set<string>();
for (const roleId of rule.allowed_role_ids) {
for (const roleId of rule.allowedRoleIds) {
const fps = roleFingerprintMap.get(roleId);
if (fps) {
for (const fp of fps) allowed.add(fp);
}
}
for (const certId of rule.allowed_cert_ids) {
for (const certId of rule.allowedCertIds) {
const fp = certFingerprintMap.get(certId);
if (fp) allowed.add(fp);
}
@@ -229,10 +229,10 @@ export function buildMtlsRbacSubroutes(
// Rules are already sorted by priority desc, path asc
for (const rule of accessRules) {
if (rule.deny_all) {
if (rule.denyAll) {
// Explicit deny: any request matching this path gets 403
subroutes.push({
match: [{ path: [rule.path_pattern] }],
match: [{ path: [rule.pathPattern] }],
handle: [{
handler: "static_response",
status_code: "403",
@@ -248,7 +248,7 @@ export function buildMtlsRbacSubroutes(
if (allowedFps.size === 0) {
// Rule exists but no certs match → deny all for this path
subroutes.push({
match: [{ path: [rule.path_pattern] }],
match: [{ path: [rule.pathPattern] }],
handle: [{
handler: "static_response",
status_code: "403",
@@ -262,14 +262,14 @@ export function buildMtlsRbacSubroutes(
// Allow route: path + fingerprint CEL match
const celExpr = buildFingerprintCelExpression(allowedFps);
subroutes.push({
match: [{ path: [rule.path_pattern], expression: celExpr }],
match: [{ path: [rule.pathPattern], expression: celExpr }],
handle: [...baseHandlers, reverseProxyHandler],
terminal: true,
});
// Deny route: path matches but fingerprint didn't → 403
subroutes.push({
match: [{ path: [rule.path_pattern] }],
match: [{ path: [rule.pathPattern] }],
handle: [{
handler: "static_response",
status_code: "403",

View File

@@ -82,14 +82,14 @@ type ProxyHostRow = {
name: string;
domains: string;
upstreams: string;
certificate_id: number | null;
access_list_id: number | null;
ssl_forced: number;
hsts_enabled: number;
hsts_subdomains: number;
allow_websocket: number;
preserve_host_header: number;
skip_https_hostname_validation: number;
certificateId: number | null;
accessListId: number | null;
sslForced: number;
hstsEnabled: number;
hstsSubdomains: number;
allowWebsocket: number;
preserveHostHeader: number;
skipHttpsHostnameValidation: number;
meta: string | null;
enabled: number;
};
@@ -216,20 +216,20 @@ type LoadBalancerRouteConfig = {
};
type AccessListEntryRow = {
access_list_id: number;
accessListId: number;
username: string;
password_hash: string;
passwordHash: string;
};
type CertificateRow = {
id: number;
name: string;
type: string;
domain_names: string;
certificate_pem: string | null;
private_key_pem: string | null;
auto_renew: number;
provider_options: string | null;
domainNames: string;
certificatePem: string | null;
privateKeyPem: string | null;
autoRenew: number;
providerOptions: string | null;
};
type CaddyHttpRoute = Record<string, unknown>;
@@ -507,15 +507,15 @@ function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number,
continue;
}
// Handle auto-managed certificates (certificate_id is null)
if (!row.certificate_id) {
// Handle auto-managed certificates (certificateId is null)
if (!row.certificateId) {
for (const domain of filteredDomains) {
autoManagedDomains.add(domain);
}
continue;
}
const cert = certificates.get(row.certificate_id);
const cert = certificates.get(row.certificateId);
if (!cert) {
continue;
}
@@ -681,9 +681,9 @@ async function buildProxyRoutes(
continue;
}
// Allow hosts with certificate_id = null (Caddy Auto) or with valid certificate IDs
const isAutoManaged = !row.certificate_id;
const hasValidCertificate = row.certificate_id && tlsReadyCertificates.has(row.certificate_id);
// Allow hosts with certificateId = null (Caddy Auto) or with valid certificate IDs
const isAutoManaged = !row.certificateId;
const hasValidCertificate = row.certificateId && tlsReadyCertificates.has(row.certificateId);
if (!isAutoManaged && !hasValidCertificate) {
continue;
@@ -720,11 +720,11 @@ async function buildProxyRoutes(
meta.waf
);
if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') {
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allow_websocket)));
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allowWebsocket)));
}
if (row.hsts_enabled) {
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
if (row.hstsEnabled) {
const value = row.hstsSubdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
handlers.push({
handler: "headers",
response: {
@@ -735,7 +735,7 @@ async function buildProxyRoutes(
});
}
if (row.ssl_forced) {
if (row.sslForced) {
for (const domainGroup of domainGroups) {
hostRoutes.push({
match: [
@@ -774,8 +774,8 @@ async function buildProxyRoutes(
});
}
if (row.access_list_id) {
const accounts = accessAccounts.get(row.access_list_id) ?? [];
if (row.accessListId) {
const accounts = accessAccounts.get(row.accessListId) ?? [];
if (accounts.length > 0) {
handlers.push({
handler: "authentication",
@@ -783,7 +783,7 @@ async function buildProxyRoutes(
http_basic: {
accounts: accounts.map((entry) => ({
username: entry.username,
password: entry.password_hash
password: entry.passwordHash
}))
}
}
@@ -856,7 +856,7 @@ async function buildProxyRoutes(
};
}
if (row.preserve_host_header) {
if (row.preserveHostHeader) {
reverseProxyHandler.headers = {
request: {
set: {
@@ -868,7 +868,7 @@ async function buildProxyRoutes(
// Configure TLS transport for HTTPS upstreams
if (resolvedUpstreams.hasHttpsUpstream) {
const tlsTransport: Record<string, unknown> = row.skip_https_hostname_validation
const tlsTransport: Record<string, unknown> = row.skipHttpsHostnameValidation
? {
insecure_skip_verify: true
}
@@ -1068,8 +1068,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1104,8 +1104,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1255,8 +1255,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1286,8 +1286,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1318,8 +1318,8 @@ async function buildProxyRoutes(
for (const rule of locationRules) {
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
rule,
Boolean(row.skip_https_hostname_validation),
Boolean(row.preserve_host_header)
Boolean(row.skipHttpsHostnameValidation),
Boolean(row.preserveHostHeader)
);
if (!safePath) continue;
hostRoutes.push({
@@ -1410,7 +1410,7 @@ function buildTlsConnectionPolicies(
}
};
// Add policy for auto-managed domains (certificate_id = null)
// Add policy for auto-managed domains (certificateId = null)
if (autoManagedDomains.size > 0) {
const domains = Array.from(autoManagedDomains);
// Split first so mTLS domains always get their own policy, regardless of auth result.
@@ -1432,14 +1432,14 @@ function buildTlsConnectionPolicies(
}
if (entry.certificate.type === "imported") {
if (!entry.certificate.certificate_pem || !entry.certificate.private_key_pem) {
if (!entry.certificate.certificatePem || !entry.certificate.privateKeyPem) {
continue;
}
// Collect PEMs for tls.certificates.load_pem (inline, no shared filesystem needed)
importedCertPems.push({
certificate: entry.certificate.certificate_pem.trim(),
key: entry.certificate.private_key_pem.trim()
certificate: entry.certificate.certificatePem.trim(),
key: entry.certificate.privateKeyPem.trim()
});
const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d));
@@ -1488,7 +1488,7 @@ async function buildTlsAutomation(
options: { acmeEmail?: string; dnsSettings?: DnsSettings | null }
) {
const managedEntries = Array.from(usage.values()).filter(
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.auto_renew)
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.autoRenew)
);
const hasAutoManagedDomains = autoManagedDomains.size > 0;
@@ -1517,7 +1517,7 @@ async function buildTlsAutomation(
const managedCertificateIds = new Set<number>();
const policies: Record<string, unknown>[] = [];
// Add policy for auto-managed domains (certificate_id = null)
// Add policy for auto-managed domains (certificateId = null)
if (hasAutoManagedDomains) {
for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) {
const issuer: Record<string, unknown> = {
@@ -1894,14 +1894,14 @@ async function buildCaddyDocument() {
name: h.name,
domains: h.domains,
upstreams: h.upstreams,
certificate_id: h.certificateId,
access_list_id: h.accessListId,
ssl_forced: h.sslForced ? 1 : 0,
hsts_enabled: h.hstsEnabled ? 1 : 0,
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
allow_websocket: h.allowWebsocket ? 1 : 0,
preserve_host_header: h.preserveHostHeader ? 1 : 0,
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
certificateId: h.certificateId,
accessListId: h.accessListId,
sslForced: h.sslForced ? 1 : 0,
hstsEnabled: h.hstsEnabled ? 1 : 0,
hstsSubdomains: h.hstsSubdomains ? 1 : 0,
allowWebsocket: h.allowWebsocket ? 1 : 0,
preserveHostHeader: h.preserveHostHeader ? 1 : 0,
skipHttpsHostnameValidation: h.skipHttpsHostnameValidation ? 1 : 0,
meta: h.meta,
enabled: h.enabled ? 1 : 0
}));
@@ -1910,17 +1910,17 @@ async function buildCaddyDocument() {
id: c.id,
name: c.name,
type: c.type as "managed" | "imported",
domain_names: c.domainNames,
certificate_pem: c.certificatePem,
private_key_pem: c.privateKeyPem,
auto_renew: c.autoRenew ? 1 : 0,
provider_options: c.providerOptions
domainNames: c.domainNames,
certificatePem: c.certificatePem,
privateKeyPem: c.privateKeyPem,
autoRenew: c.autoRenew ? 1 : 0,
providerOptions: c.providerOptions
}));
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
access_list_id: entry.accessListId,
accessListId: entry.accessListId,
username: entry.username,
password_hash: entry.passwordHash
passwordHash: entry.passwordHash
}));
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
@@ -1933,10 +1933,10 @@ async function buildCaddyDocument() {
}, new Map());
const cAsWithAnyIssuedCerts = new Set(allIssuedCaCertIds.map(r => r.caCertificateId));
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
if (!map.has(entry.access_list_id)) {
map.set(entry.access_list_id, []);
if (!map.has(entry.accessListId)) {
map.set(entry.accessListId, []);
}
map.get(entry.access_list_id)!.push(entry);
map.get(entry.accessListId)!.push(entry);
return map;
}, new Map());
@@ -2187,7 +2187,7 @@ export async function applyCaddyConfig() {
const document = await buildCaddyDocument();
const payload = JSON.stringify(document);
const hash = crypto.createHash("sha256").update(payload).digest("hex");
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
setSetting("caddy_config_hash", { hash, updatedAt: nowIso() });
try {
const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload);

View File

@@ -1,6 +1,8 @@
import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import { eq, ne, and, isNull } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { mkdirSync } from "node:fs";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import * as schema from "./db/schema";
@@ -58,7 +60,7 @@ function ensureDirectoryFor(pathname: string) {
const globalForDrizzle = globalThis as GlobalForDrizzle;
const sqlite =
export const sqlite =
globalForDrizzle.__SQLITE_CLIENT__ ??
(() => {
ensureDirectoryFor(sqlitePath);
@@ -70,7 +72,7 @@ if (process.env.NODE_ENV !== "production") {
}
export const db =
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" });
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema });
if (process.env.NODE_ENV !== "production") {
globalForDrizzle.__DRIZZLE_DB__ = db;
@@ -121,6 +123,142 @@ try {
}
}
/**
* One-time migration: populate `accounts` table from existing users' provider/subject fields.
* Also creates credential accounts for password users and syncs env OAuth providers.
* Idempotent — skips if already run (checked via settings flag).
*/
function runBetterAuthDataMigration() {
if (sqlitePath === ":memory:") return;
const { settings, users, accounts } = schema;
const flag = db.select().from(settings).where(eq(settings.key, "better_auth_migrated")).get();
if (flag) return;
const now = new Date().toISOString();
// Migrate OAuth users: create account rows from users.provider/subject
const oauthUsers = db.select().from(users).where(ne(users.provider, "credentials")).all();
for (const user of oauthUsers) {
if (!user.provider || !user.subject) continue;
const existing = db.select().from(accounts).where(
and(eq(accounts.userId, user.id), eq(accounts.providerId, user.provider), eq(accounts.accountId, user.subject))
).get();
if (!existing) {
db.insert(accounts).values({
userId: user.id,
accountId: user.subject,
providerId: user.provider,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}).run();
}
}
// Migrate credentials users: create credential account rows
const credentialUsers = db.select().from(users).where(eq(users.provider, "credentials")).all();
for (const user of credentialUsers) {
const existing = db.select().from(accounts).where(
and(eq(accounts.userId, user.id), eq(accounts.providerId, "credential"))
).get();
if (!existing) {
db.insert(accounts).values({
userId: user.id,
accountId: user.id.toString(),
providerId: "credential",
password: user.passwordHash,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
}).run();
}
}
// Populate username field for all users (derived from email prefix)
const usersWithoutUsername = db.select().from(users).where(isNull(users.username)).all();
for (const user of usersWithoutUsername) {
const usernameFromEmail = user.email.split("@")[0] || user.email;
db.update(users).set({
username: usernameFromEmail.toLowerCase(),
displayUsername: usernameFromEmail,
}).where(eq(users.id, user.id)).run();
}
db.insert(settings).values({ key: "better_auth_migrated", value: "true", updatedAt: now }).run();
console.log("Better Auth data migration complete: populated accounts table");
}
/**
* Sync OAUTH_* env vars into the oauthProviders table (synchronous).
* Uses raw Drizzle queries since this runs at module load time.
*/
function runEnvProviderSync() {
if (sqlitePath === ":memory:") return;
// Lazy import to avoid circular dependency at module load
let config: { oauth: { enabled: boolean; providerName: string; clientId: string | null; clientSecret: string | null; issuer: string | null; authorizationUrl: string | null; tokenUrl: string | null; userinfoUrl: string | null; allowAutoLinking: boolean } };
try {
config = require("./config").config;
} catch {
return;
}
if (!config.oauth.enabled || !config.oauth.clientId || !config.oauth.clientSecret) return;
const { oauthProviders } = schema;
let encryptSecret: (v: string) => string;
try {
encryptSecret = require("./secret").encryptSecret;
} catch {
encryptSecret = (v) => v;
}
const name = config.oauth.providerName;
// Use a slug-based ID so the OAuth callback URL is predictable
const providerId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth";
const existing = db.select().from(oauthProviders).where(eq(oauthProviders.name, name)).get();
const now = new Date().toISOString();
if (existing && existing.source === "env") {
db.update(oauthProviders).set({
clientId: encryptSecret(config.oauth.clientId),
clientSecret: encryptSecret(config.oauth.clientSecret),
issuer: config.oauth.issuer ?? null,
authorizationUrl: config.oauth.authorizationUrl ?? null,
tokenUrl: config.oauth.tokenUrl ?? null,
userinfoUrl: config.oauth.userinfoUrl ?? null,
autoLink: config.oauth.allowAutoLinking,
updatedAt: now,
}).where(eq(oauthProviders.id, existing.id)).run();
} else if (!existing) {
db.insert(oauthProviders).values({
id: providerId,
name,
type: "oidc",
clientId: encryptSecret(config.oauth.clientId),
clientSecret: encryptSecret(config.oauth.clientSecret),
issuer: config.oauth.issuer ?? null,
authorizationUrl: config.oauth.authorizationUrl ?? null,
tokenUrl: config.oauth.tokenUrl ?? null,
userinfoUrl: config.oauth.userinfoUrl ?? null,
scopes: "openid email profile",
autoLink: config.oauth.allowAutoLinking,
enabled: true,
source: "env",
createdAt: now,
updatedAt: now,
}).run();
console.log(`Synced OAuth provider from env: ${name}`);
}
}
try {
runBetterAuthDataMigration();
runEnvProviderSync();
} catch (error) {
console.warn("Better Auth data migration warning:", error);
}
export { schema };
export default db;

View File

@@ -6,34 +6,102 @@ export const users = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
name: text("name"),
passwordHash: text("password_hash"),
passwordHash: text("passwordHash"),
role: text("role").notNull().default("user"),
provider: text("provider").notNull(),
subject: text("subject").notNull(),
avatarUrl: text("avatar_url"),
provider: text("provider"),
subject: text("subject"),
avatarUrl: text("avatarUrl"),
status: text("status").notNull().default("active"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
username: text("username"),
displayUsername: text("displayUsername"),
emailVerified: integer("emailVerified", { mode: "boolean" }).notNull().default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
emailUnique: uniqueIndex("users_email_unique").on(table.email),
providerSubjectIdx: uniqueIndex("users_provider_subject_idx").on(table.provider, table.subject)
emailUnique: uniqueIndex("users_email_unique").on(table.email)
})
);
// Auth tables use camelCase DB columns to match Better Auth's Kysely adapter.
export const sessions = sqliteTable(
"sessions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
userId: integer("userId")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
token: text("token").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull()
expiresAt: text("expiresAt").notNull(),
ipAddress: text("ipAddress"),
userAgent: text("userAgent"),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token)
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token),
userIdx: index("sessions_user_idx").on(table.userId)
})
);
export const accounts = sqliteTable(
"accounts",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("userId")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
accountId: text("accountId").notNull(),
providerId: text("providerId").notNull(),
accessToken: text("accessToken"),
refreshToken: text("refreshToken"),
idToken: text("idToken"),
accessTokenExpiresAt: text("accessTokenExpiresAt"),
refreshTokenExpiresAt: text("refreshTokenExpiresAt"),
scope: text("scope"),
password: text("password"),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
providerAccountIdx: uniqueIndex("accounts_provider_account_idx").on(table.providerId, table.accountId),
userIdx: index("accounts_user_idx").on(table.userId)
})
);
export const verifications = sqliteTable(
"verifications",
{
id: integer("id").primaryKey({ autoIncrement: true }),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: text("expiresAt").notNull(),
createdAt: text("createdAt"),
updatedAt: text("updatedAt")
}
);
export const oauthProviders = sqliteTable(
"oauth_providers",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
type: text("type").notNull().default("oidc"),
clientId: text("clientId").notNull(),
clientSecret: text("clientSecret").notNull(),
issuer: text("issuer"),
authorizationUrl: text("authorizationUrl"),
tokenUrl: text("tokenUrl"),
userinfoUrl: text("userinfoUrl"),
scopes: text("scopes").notNull().default("openid email profile"),
autoLink: integer("autoLink", { mode: "boolean" }).notNull().default(false),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
source: text("source").notNull().default("ui"),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
nameUnique: uniqueIndex("oauth_providers_name_unique").on(table.name)
})
);
@@ -42,10 +110,10 @@ export const oauthStates = sqliteTable(
{
id: integer("id").primaryKey({ autoIncrement: true }),
state: text("state").notNull(),
codeVerifier: text("code_verifier").notNull(),
redirectTo: text("redirect_to"),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
codeVerifier: text("codeVerifier").notNull(),
redirectTo: text("redirectTo"),
createdAt: text("createdAt").notNull(),
expiresAt: text("expiresAt").notNull()
},
(table) => ({
stateUnique: uniqueIndex("oauth_state_unique").on(table.state)
@@ -54,11 +122,11 @@ export const oauthStates = sqliteTable(
export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
userId: integer("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
provider: text("provider", { length: 50 }).notNull(),
userEmail: text("user_email").notNull(), // Email of the user who initiated linking
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
userEmail: text("userEmail").notNull(), // Email of the user who initiated linking
createdAt: text("createdAt").notNull(),
expiresAt: text("expiresAt").notNull()
}, (table) => ({
// Ensure only one pending link per user per provider (prevents race conditions)
userProviderUnique: uniqueIndex("pending_oauth_user_provider_unique").on(table.userId, table.provider)
@@ -67,7 +135,7 @@ export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
export const settings = sqliteTable("settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: text("updated_at").notNull()
updatedAt: text("updatedAt").notNull()
});
export const instances = sqliteTable(
@@ -75,13 +143,13 @@ export const instances = sqliteTable(
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
baseUrl: text("base_url").notNull(),
apiToken: text("api_token").notNull(),
baseUrl: text("baseUrl").notNull(),
apiToken: text("apiToken").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSyncAt: text("last_sync_at"),
lastSyncError: text("last_sync_error"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
lastSyncAt: text("lastSyncAt"),
lastSyncError: text("lastSyncError"),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
baseUrlUnique: uniqueIndex("instances_base_url_unique").on(table.baseUrl)
@@ -92,22 +160,22 @@ export const accessLists = sqliteTable("access_lists", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const accessListEntries = sqliteTable(
"access_list_entries",
{
id: integer("id").primaryKey({ autoIncrement: true }),
accessListId: integer("access_list_id")
accessListId: integer("accessListId")
.references(() => accessLists.id, { onDelete: "cascade" })
.notNull(),
username: text("username").notNull(),
passwordHash: text("password_hash").notNull(),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
passwordHash: text("passwordHash").notNull(),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
accessListIdIdx: index("access_list_entries_list_idx").on(table.accessListId)
@@ -118,43 +186,43 @@ export const certificates = sqliteTable("certificates", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
type: text("type").notNull(),
domainNames: text("domain_names").notNull(),
autoRenew: integer("auto_renew", { mode: "boolean" }).notNull().default(true),
providerOptions: text("provider_options"),
certificatePem: text("certificate_pem"),
privateKeyPem: text("private_key_pem"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
domainNames: text("domainNames").notNull(),
autoRenew: integer("autoRenew", { mode: "boolean" }).notNull().default(true),
providerOptions: text("providerOptions"),
certificatePem: text("certificatePem"),
privateKeyPem: text("privateKeyPem"),
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const caCertificates = sqliteTable("ca_certificates", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
certificatePem: text("certificate_pem").notNull(),
privateKeyPem: text("private_key_pem"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
certificatePem: text("certificatePem").notNull(),
privateKeyPem: text("privateKeyPem"),
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const issuedClientCertificates = sqliteTable(
"issued_client_certificates",
{
id: integer("id").primaryKey({ autoIncrement: true }),
caCertificateId: integer("ca_certificate_id")
caCertificateId: integer("caCertificateId")
.references(() => caCertificates.id, { onDelete: "cascade" })
.notNull(),
commonName: text("common_name").notNull(),
serialNumber: text("serial_number").notNull(),
fingerprintSha256: text("fingerprint_sha256").notNull(),
certificatePem: text("certificate_pem").notNull(),
validFrom: text("valid_from").notNull(),
validTo: text("valid_to").notNull(),
revokedAt: text("revoked_at"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
commonName: text("commonName").notNull(),
serialNumber: text("serialNumber").notNull(),
fingerprintSha256: text("fingerprintSha256").notNull(),
certificatePem: text("certificatePem").notNull(),
validFrom: text("validFrom").notNull(),
validTo: text("validTo").notNull(),
revokedAt: text("revokedAt"),
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
caCertificateIdx: index("issued_client_certificates_ca_idx").on(table.caCertificateId),
@@ -167,19 +235,19 @@ export const proxyHosts = sqliteTable("proxy_hosts", {
name: text("name").notNull(),
domains: text("domains").notNull(),
upstreams: text("upstreams").notNull(),
certificateId: integer("certificate_id").references(() => certificates.id, { onDelete: "set null" }),
accessListId: integer("access_list_id").references(() => accessLists.id, { onDelete: "set null" }),
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
sslForced: integer("ssl_forced", { mode: "boolean" }).notNull().default(true),
hstsEnabled: integer("hsts_enabled", { mode: "boolean" }).notNull().default(true),
hstsSubdomains: integer("hsts_subdomains", { mode: "boolean" }).notNull().default(false),
allowWebsocket: integer("allow_websocket", { mode: "boolean" }).notNull().default(true),
preserveHostHeader: integer("preserve_host_header", { mode: "boolean" }).notNull().default(true),
certificateId: integer("certificateId").references(() => certificates.id, { onDelete: "set null" }),
accessListId: integer("accessListId").references(() => accessLists.id, { onDelete: "set null" }),
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
sslForced: integer("sslForced", { mode: "boolean" }).notNull().default(true),
hstsEnabled: integer("hstsEnabled", { mode: "boolean" }).notNull().default(true),
hstsSubdomains: integer("hstsSubdomains", { mode: "boolean" }).notNull().default(false),
allowWebsocket: integer("allowWebsocket", { mode: "boolean" }).notNull().default(true),
preserveHostHeader: integer("preserveHostHeader", { mode: "boolean" }).notNull().default(true),
meta: text("meta"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull(),
skipHttpsHostnameValidation: integer("skipHttpsHostnameValidation", { mode: "boolean" })
.notNull()
.default(false)
});
@@ -189,13 +257,13 @@ export const apiTokens = sqliteTable(
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
tokenHash: text("token_hash").notNull(),
createdBy: integer("created_by")
tokenHash: text("tokenHash").notNull(),
createdBy: integer("createdBy")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull(),
lastUsedAt: text("last_used_at"),
expiresAt: text("expires_at")
createdAt: text("createdAt").notNull(),
lastUsedAt: text("lastUsedAt"),
expiresAt: text("expiresAt")
},
(table) => ({
tokenHashUnique: uniqueIndex("api_tokens_token_hash_unique").on(table.tokenHash)
@@ -204,20 +272,20 @@ export const apiTokens = sqliteTable(
export const auditEvents = sqliteTable("audit_events", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
userId: integer("userId").references(() => users.id, { onDelete: "set null" }),
action: text("action").notNull(),
entityType: text("entity_type").notNull(),
entityId: integer("entity_id"),
entityType: text("entityType").notNull(),
entityId: integer("entityId"),
summary: text("summary"),
data: text("data"),
createdAt: text("created_at").notNull()
createdAt: text("createdAt").notNull()
});
export const linkingTokens = sqliteTable("linking_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull(),
createdAt: text("created_at").notNull(),
expiresAt: text("expires_at").notNull()
createdAt: text("createdAt").notNull(),
expiresAt: text("expiresAt").notNull()
});
// traffic_events and waf_events have been migrated to ClickHouse.
@@ -241,9 +309,9 @@ export const mtlsRoles = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
@@ -254,13 +322,13 @@ export const mtlsCertificateRoles = sqliteTable(
"mtls_certificate_roles",
{
id: integer("id").primaryKey({ autoIncrement: true }),
issuedClientCertificateId: integer("issued_client_certificate_id")
issuedClientCertificateId: integer("issuedClientCertificateId")
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
.notNull(),
mtlsRoleId: integer("mtls_role_id")
mtlsRoleId: integer("mtlsRoleId")
.references(() => mtlsRoles.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull()
createdAt: text("createdAt").notNull()
},
(table) => ({
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
@@ -275,18 +343,18 @@ export const mtlsAccessRules = sqliteTable(
"mtls_access_rules",
{
id: integer("id").primaryKey({ autoIncrement: true }),
proxyHostId: integer("proxy_host_id")
proxyHostId: integer("proxyHostId")
.references(() => proxyHosts.id, { onDelete: "cascade" })
.notNull(),
pathPattern: text("path_pattern").notNull(),
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
pathPattern: text("pathPattern").notNull(),
allowedRoleIds: text("allowedRoleIds").notNull().default("[]"),
allowedCertIds: text("allowedCertIds").notNull().default("[]"),
denyAll: integer("denyAll", { mode: "boolean" }).notNull().default(false),
priority: integer("priority").notNull().default(0),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
@@ -305,9 +373,9 @@ export const groups = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
},
(table) => ({
nameUnique: uniqueIndex("groups_name_unique").on(table.name)
@@ -318,13 +386,13 @@ export const groupMembers = sqliteTable(
"group_members",
{
id: integer("id").primaryKey({ autoIncrement: true }),
groupId: integer("group_id")
groupId: integer("groupId")
.references(() => groups.id, { onDelete: "cascade" })
.notNull(),
userId: integer("user_id")
userId: integer("userId")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
createdAt: text("created_at").notNull()
createdAt: text("createdAt").notNull()
},
(table) => ({
memberUnique: uniqueIndex("group_members_unique").on(table.groupId, table.userId),
@@ -336,12 +404,12 @@ export const forwardAuthAccess = sqliteTable(
"forward_auth_access",
{
id: integer("id").primaryKey({ autoIncrement: true }),
proxyHostId: integer("proxy_host_id")
proxyHostId: integer("proxyHostId")
.references(() => proxyHosts.id, { onDelete: "cascade" })
.notNull(),
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }),
groupId: integer("group_id").references(() => groups.id, { onDelete: "cascade" }),
createdAt: text("created_at").notNull()
userId: integer("userId").references(() => users.id, { onDelete: "cascade" }),
groupId: integer("groupId").references(() => groups.id, { onDelete: "cascade" }),
createdAt: text("createdAt").notNull()
},
(table) => ({
hostIdx: index("faa_host_idx").on(table.proxyHostId),
@@ -354,12 +422,12 @@ export const forwardAuthSessions = sqliteTable(
"forward_auth_sessions",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
userId: integer("userId")
.references(() => users.id, { onDelete: "cascade" })
.notNull(),
tokenHash: text("token_hash").notNull(),
expiresAt: text("expires_at").notNull(),
createdAt: text("created_at").notNull()
tokenHash: text("tokenHash").notNull(),
expiresAt: text("expiresAt").notNull(),
createdAt: text("createdAt").notNull()
},
(table) => ({
tokenHashUnique: uniqueIndex("fas_token_hash_unique").on(table.tokenHash),
@@ -372,15 +440,15 @@ export const forwardAuthExchanges = sqliteTable(
"forward_auth_exchanges",
{
id: integer("id").primaryKey({ autoIncrement: true }),
sessionId: integer("session_id")
sessionId: integer("sessionId")
.references(() => forwardAuthSessions.id, { onDelete: "cascade" })
.notNull(),
codeHash: text("code_hash").notNull(),
sessionToken: text("session_token").notNull(), // raw session token (short-lived, single-use)
redirectUri: text("redirect_uri").notNull(),
expiresAt: text("expires_at").notNull(),
codeHash: text("codeHash").notNull(),
sessionToken: text("sessionToken").notNull(), // raw session token (short-lived, single-use)
redirectUri: text("redirectUri").notNull(),
expiresAt: text("expiresAt").notNull(),
used: integer("used", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull()
createdAt: text("createdAt").notNull()
},
(table) => ({
codeHashUnique: uniqueIndex("fae_code_hash_unique").on(table.codeHash)
@@ -391,11 +459,11 @@ export const forwardAuthRedirectIntents = sqliteTable(
"forward_auth_redirect_intents",
{
id: integer("id").primaryKey({ autoIncrement: true }),
ridHash: text("rid_hash").notNull(),
redirectUri: text("redirect_uri").notNull(),
expiresAt: text("expires_at").notNull(),
ridHash: text("ridHash").notNull(),
redirectUri: text("redirectUri").notNull(),
expiresAt: text("expiresAt").notNull(),
consumed: integer("consumed", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at").notNull()
createdAt: text("createdAt").notNull()
},
(table) => ({
ridHashUnique: uniqueIndex("fari_rid_hash_unique").on(table.ridHash),
@@ -409,16 +477,16 @@ export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
protocol: text("protocol").notNull(),
listenAddress: text("listen_address").notNull(),
listenAddress: text("listenAddress").notNull(),
upstreams: text("upstreams").notNull(),
matcherType: text("matcher_type").notNull().default("none"),
matcherValue: text("matcher_value"),
tlsTermination: integer("tls_termination", { mode: "boolean" }).notNull().default(false),
proxyProtocolVersion: text("proxy_protocol_version"),
proxyProtocolReceive: integer("proxy_protocol_receive", { mode: "boolean" }).notNull().default(false),
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
matcherType: text("matcherType").notNull().default("none"),
matcherValue: text("matcherValue"),
tlsTermination: integer("tlsTermination", { mode: "boolean" }).notNull().default(false),
proxyProtocolVersion: text("proxyProtocolVersion"),
proxyProtocolReceive: integer("proxyProtocolReceive", { mode: "boolean" }).notNull().default(false),
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
meta: text("meta"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull(),
});

View File

@@ -1,8 +1,9 @@
import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
import db, { nowIso } from "./db";
import { config } from "./config";
import { users } from "./db/schema";
import { eq } from "drizzle-orm";
import { users, accounts } from "./db/schema";
import { and, eq } from "drizzle-orm";
/**
* Ensures the admin user from environment variables exists in the database.
@@ -37,9 +38,13 @@ export async function ensureAdminUser(): Promise<void> {
subject,
passwordHash,
role: "admin",
username: config.adminUsername.toLowerCase(),
displayUsername: config.adminUsername,
updatedAt: now
})
.where(eq(users.id, adminId));
// Ensure credential account row exists for Better Auth
await ensureCredentialAccount(adminId, passwordHash);
console.log(`Updated admin user: ${config.adminUsername}`);
return;
}
@@ -54,6 +59,8 @@ export async function ensureAdminUser(): Promise<void> {
role: "admin",
provider,
subject,
username: config.adminUsername.toLowerCase(),
displayUsername: config.adminUsername,
avatarUrl: null,
status: "active",
createdAt: now,
@@ -61,4 +68,35 @@ export async function ensureAdminUser(): Promise<void> {
});
console.log(`Created admin user: ${config.adminUsername}`);
// Ensure credential account row exists for Better Auth
await ensureCredentialAccount(adminId, passwordHash);
}
/**
* Ensures a credential account row exists in the accounts table for Better Auth.
* Better Auth requires an accounts row with providerId="credential" and the password hash.
*/
async function ensureCredentialAccount(userId: number, passwordHash: string): Promise<void> {
const now = nowIso();
const existing = await db.select().from(accounts).where(
and(eq(accounts.userId, userId), eq(accounts.providerId, "credential"))
).get();
if (existing) {
// Update password hash if changed
await db.update(accounts).set({
password: passwordHash,
updatedAt: now,
}).where(eq(accounts.id, existing.id));
} else {
await db.insert(accounts).values({
userId,
accountId: userId.toString(),
providerId: "credential",
password: passwordHash,
createdAt: now,
updatedAt: now,
});
}
}

View File

@@ -8,8 +8,8 @@ import { asc, eq, inArray, count } from "drizzle-orm";
export type AccessListEntry = {
id: number;
username: string;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
export type AccessList = {
@@ -17,8 +17,8 @@ export type AccessList = {
name: string;
description: string | null;
entries: AccessListEntry[];
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
export type AccessListInput = {
@@ -34,8 +34,8 @@ function buildEntry(row: AccessListEntryRow): AccessListEntry {
return {
id: row.id,
username: row.username,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}
@@ -48,8 +48,8 @@ function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): Access
.slice()
.sort((a, b) => a.username.localeCompare(b.username))
.map(buildEntry),
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}

View File

@@ -7,10 +7,10 @@ import { NotFoundError } from "../api-auth";
export type ApiToken = {
id: number;
name: string;
created_by: number;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
createdBy: number;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
};
type ApiTokenRow = typeof apiTokens.$inferSelect;
@@ -19,10 +19,10 @@ function toApiToken(row: ApiTokenRow): ApiToken {
return {
id: row.id,
name: row.name,
created_by: row.createdBy,
created_at: toIso(row.createdAt)!,
last_used_at: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
expires_at: row.expiresAt ? toIso(row.expiresAt) : null,
createdBy: row.createdBy,
createdAt: toIso(row.createdAt)!,
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
expiresAt: row.expiresAt ? toIso(row.expiresAt) : null,
};
}

View File

@@ -4,12 +4,12 @@ import { desc, like, or, count } from "drizzle-orm";
export type AuditEvent = {
id: number;
user_id: number | null;
userId: number | null;
action: string;
entity_type: string;
entity_id: number | null;
entityType: string;
entityId: number | null;
summary: string | null;
created_at: string;
createdAt: string;
};
// Escape LIKE metacharacters so user input is treated as literal text
@@ -57,12 +57,12 @@ export async function listAuditEvents(
return events.map((event) => ({
id: event.id,
user_id: event.userId,
userId: event.userId,
action: event.action,
entity_type: event.entityType,
entity_id: event.entityId,
entityType: event.entityType,
entityId: event.entityId,
summary: event.summary,
created_at: toIso(event.createdAt)!,
createdAt: toIso(event.createdAt)!,
}));
}

View File

@@ -16,16 +16,16 @@ function tryParseJson<T>(value: string | null | undefined, fallback: T): T {
export type CaCertificate = {
id: number;
name: string;
certificate_pem: string;
has_private_key: boolean;
created_at: string;
updated_at: string;
certificatePem: string;
hasPrivateKey: boolean;
createdAt: string;
updatedAt: string;
};
export type CaCertificateInput = {
name: string;
certificate_pem: string;
private_key_pem?: string;
certificatePem: string;
privateKeyPem?: string;
};
type CaCertificateRow = typeof caCertificates.$inferSelect;
@@ -34,10 +34,10 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
return {
id: row.id,
name: row.name,
certificate_pem: row.certificatePem,
has_private_key: !!row.privateKeyPem,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
certificatePem: row.certificatePem,
hasPrivateKey: !!row.privateKeyPem,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}
@@ -66,8 +66,8 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
.insert(caCertificates)
.values({
name: input.name.trim(),
certificatePem: input.certificate_pem.trim(),
privateKeyPem: input.private_key_pem?.trim() ?? null,
certificatePem: input.certificatePem.trim(),
privateKeyPem: input.privateKeyPem?.trim() ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now
@@ -100,8 +100,8 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
.update(caCertificates)
.set({
name: input.name?.trim() ?? existing.name,
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem,
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}),
certificatePem: input.certificatePem?.trim() ?? existing.certificatePem,
...(input.privateKeyPem !== undefined ? { privateKeyPem: input.privateKeyPem?.trim() ?? null } : {}),
updatedAt: now
})
.where(eq(caCertificates.id, id));

View File

@@ -10,23 +10,23 @@ export type Certificate = {
id: number;
name: string;
type: CertificateType;
domain_names: string[];
auto_renew: boolean;
provider_options: Record<string, unknown> | null;
certificate_pem: string | null;
private_key_pem: string | null;
created_at: string;
updated_at: string;
domainNames: string[];
autoRenew: boolean;
providerOptions: Record<string, unknown> | null;
certificatePem: string | null;
privateKeyPem: string | null;
createdAt: string;
updatedAt: string;
};
export type CertificateInput = {
name: string;
type: CertificateType;
domain_names: string[];
auto_renew?: boolean;
provider_options?: Record<string, unknown> | null;
certificate_pem?: string | null;
private_key_pem?: string | null;
domainNames: string[];
autoRenew?: boolean;
providerOptions?: Record<string, unknown> | null;
certificatePem?: string | null;
privateKeyPem?: string | null;
};
type CertificateRow = typeof certificates.$inferSelect;
@@ -36,13 +36,13 @@ function parseCertificate(row: CertificateRow): Certificate {
id: row.id,
name: row.name,
type: row.type as CertificateType,
domain_names: JSON.parse(row.domainNames),
auto_renew: row.autoRenew,
provider_options: row.providerOptions ? JSON.parse(row.providerOptions) : null,
certificate_pem: row.certificatePem,
private_key_pem: row.privateKeyPem,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
domainNames: JSON.parse(row.domainNames),
autoRenew: row.autoRenew,
providerOptions: row.providerOptions ? JSON.parse(row.providerOptions) : null,
certificatePem: row.certificatePem,
privateKeyPem: row.privateKeyPem,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}
@@ -59,11 +59,11 @@ export async function getCertificate(id: number): Promise<Certificate | null> {
}
function validateCertificateInput(input: CertificateInput) {
if (!input.domain_names || input.domain_names.length === 0) {
if (!input.domainNames || input.domainNames.length === 0) {
throw new Error("At least one domain is required for a certificate");
}
if (input.type === "imported") {
if (!input.certificate_pem || !input.private_key_pem) {
if (!input.certificatePem || !input.privateKeyPem) {
throw new Error("Imported certificates require certificate and key PEM data");
}
}
@@ -78,12 +78,12 @@ export async function createCertificate(input: CertificateInput, actorUserId: nu
name: input.name.trim(),
type: input.type,
domainNames: JSON.stringify(
Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))
Array.from(new Set(input.domainNames.map((domain) => domain.trim().toLowerCase())))
),
autoRenew: input.auto_renew ?? true,
providerOptions: input.provider_options ? JSON.stringify(input.provider_options) : null,
certificatePem: input.certificate_pem ?? null,
privateKeyPem: input.private_key_pem ?? null,
autoRenew: input.autoRenew ?? true,
providerOptions: input.providerOptions ? JSON.stringify(input.providerOptions) : null,
certificatePem: input.certificatePem ?? null,
privateKeyPem: input.privateKeyPem ?? null,
createdAt: now,
updatedAt: now,
createdBy: actorUserId
@@ -114,11 +114,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
const merged: CertificateInput = {
name: input.name ?? existing.name,
type: input.type ?? existing.type,
domain_names: input.domain_names ?? existing.domain_names,
auto_renew: input.auto_renew ?? existing.auto_renew,
provider_options: input.provider_options ?? existing.provider_options,
certificate_pem: input.certificate_pem ?? existing.certificate_pem,
private_key_pem: input.private_key_pem ?? existing.private_key_pem
domainNames: input.domainNames ?? existing.domainNames,
autoRenew: input.autoRenew ?? existing.autoRenew,
providerOptions: input.providerOptions ?? existing.providerOptions,
certificatePem: input.certificatePem ?? existing.certificatePem,
privateKeyPem: input.privateKeyPem ?? existing.privateKeyPem
};
validateCertificateInput(merged);
@@ -129,11 +129,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
.set({
name: merged.name.trim(),
type: merged.type,
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
autoRenew: merged.auto_renew,
providerOptions: merged.provider_options ? JSON.stringify(merged.provider_options) : null,
certificatePem: merged.certificate_pem ?? null,
privateKeyPem: merged.private_key_pem ?? null,
domainNames: JSON.stringify(Array.from(new Set(merged.domainNames))),
autoRenew: merged.autoRenew,
providerOptions: merged.providerOptions ? JSON.stringify(merged.providerOptions) : null,
certificatePem: merged.certificatePem ?? null,
privateKeyPem: merged.privateKeyPem ?? null,
updatedAt: now
})
.where(eq(certificates.id, id));

View File

@@ -92,9 +92,9 @@ export async function consumeRedirectIntent(
export type ForwardAuthSession = {
id: number;
user_id: number;
expires_at: string;
created_at: string;
userId: number;
expiresAt: string;
createdAt: string;
};
export async function createForwardAuthSession(
@@ -118,9 +118,9 @@ export async function createForwardAuthSession(
rawToken,
session: {
id: row.id,
user_id: row.userId,
expires_at: toIso(row.expiresAt)!,
created_at: toIso(row.createdAt)!
userId: row.userId,
expiresAt: toIso(row.expiresAt)!,
createdAt: toIso(row.createdAt)!
}
};
}
@@ -145,9 +145,9 @@ export async function listForwardAuthSessions(): Promise<ForwardAuthSession[]> {
});
return rows.map((r) => ({
id: r.id,
user_id: r.userId,
expires_at: toIso(r.expiresAt)!,
created_at: toIso(r.createdAt)!
userId: r.userId,
expiresAt: toIso(r.expiresAt)!,
createdAt: toIso(r.createdAt)!
}));
}
@@ -232,10 +232,10 @@ export async function redeemExchangeCode(
export type ForwardAuthAccessEntry = {
id: number;
proxy_host_id: number;
user_id: number | null;
group_id: number | null;
created_at: string;
proxyHostId: number;
userId: number | null;
groupId: number | null;
createdAt: string;
};
export async function checkHostAccess(
@@ -313,10 +313,10 @@ export async function getForwardAuthAccessForHost(
return rows.map((r) => ({
id: r.id,
proxy_host_id: r.proxyHostId,
user_id: r.userId,
group_id: r.groupId,
created_at: toIso(r.createdAt)!
proxyHostId: r.proxyHostId,
userId: r.userId,
groupId: r.groupId,
createdAt: toIso(r.createdAt)!
}));
}

View File

@@ -8,15 +8,15 @@ export type Group = {
name: string;
description: string | null;
members: GroupMember[];
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
export type GroupMember = {
user_id: number;
userId: number;
email: string;
name: string | null;
created_at: string;
createdAt: string;
};
export type GroupInput = {
@@ -32,8 +32,8 @@ function toGroup(row: GroupRow, members: GroupMember[]): Group {
name: row.name,
description: row.description,
members,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}
@@ -61,10 +61,10 @@ export async function listGroups(): Promise<Group[]> {
for (const m of allMembers) {
const bucket = membersByGroup.get(m.groupId) ?? [];
bucket.push({
user_id: m.userId,
userId: m.userId,
email: m.email,
name: m.name,
created_at: toIso(m.createdAt)!
createdAt: toIso(m.createdAt)!
});
membersByGroup.set(m.groupId, bucket);
}
@@ -97,10 +97,10 @@ export async function getGroup(id: number): Promise<Group | null> {
return toGroup(
group,
members.map((m) => ({
user_id: m.userId,
userId: m.userId,
email: m.email,
name: m.name,
created_at: toIso(m.createdAt)!
createdAt: toIso(m.createdAt)!
}))
);
}

View File

@@ -6,13 +6,13 @@ import { encryptSecret } from "../secret";
export type Instance = {
id: number;
name: string;
base_url: string;
baseUrl: string;
enabled: boolean;
has_token: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
created_at: string;
updated_at: string;
hasToken: boolean;
lastSyncAt: string | null;
lastSyncError: string | null;
createdAt: string;
updatedAt: string;
};
export type InstanceInput = {
@@ -28,13 +28,13 @@ function toInstance(row: InstanceRow): Instance {
return {
id: row.id,
name: row.name,
base_url: row.baseUrl,
baseUrl: row.baseUrl,
enabled: Boolean(row.enabled),
has_token: row.apiToken.length > 0,
last_sync_at: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
last_sync_error: row.lastSyncError ?? null,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
hasToken: row.apiToken.length > 0,
lastSyncAt: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
lastSyncError: row.lastSyncError ?? null,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}

View File

@@ -6,26 +6,26 @@ import { desc, eq } from "drizzle-orm";
export type IssuedClientCertificate = {
id: number;
ca_certificate_id: number;
common_name: string;
serial_number: string;
fingerprint_sha256: string;
certificate_pem: string;
valid_from: string;
valid_to: string;
revoked_at: string | null;
created_at: string;
updated_at: string;
caCertificateId: number;
commonName: string;
serialNumber: string;
fingerprintSha256: string;
certificatePem: string;
validFrom: string;
validTo: string;
revokedAt: string | null;
createdAt: string;
updatedAt: string;
};
export type IssuedClientCertificateInput = {
ca_certificate_id: number;
common_name: string;
serial_number: string;
fingerprint_sha256: string;
certificate_pem: string;
valid_from: string;
valid_to: string;
caCertificateId: number;
commonName: string;
serialNumber: string;
fingerprintSha256: string;
certificatePem: string;
validFrom: string;
validTo: string;
};
type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
@@ -33,16 +33,16 @@ type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
function parseIssuedClientCertificate(row: IssuedClientCertificateRow): IssuedClientCertificate {
return {
id: row.id,
ca_certificate_id: row.caCertificateId,
common_name: row.commonName,
serial_number: row.serialNumber,
fingerprint_sha256: row.fingerprintSha256,
certificate_pem: row.certificatePem,
valid_from: toIso(row.validFrom)!,
valid_to: toIso(row.validTo)!,
revoked_at: toIso(row.revokedAt),
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
caCertificateId: row.caCertificateId,
commonName: row.commonName,
serialNumber: row.serialNumber,
fingerprintSha256: row.fingerprintSha256,
certificatePem: row.certificatePem,
validFrom: toIso(row.validFrom)!,
validTo: toIso(row.validTo)!,
revokedAt: toIso(row.revokedAt),
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!
};
}
@@ -69,13 +69,13 @@ export async function createIssuedClientCertificate(
const [record] = await db
.insert(issuedClientCertificates)
.values({
caCertificateId: input.ca_certificate_id,
commonName: input.common_name.trim(),
serialNumber: input.serial_number.trim(),
fingerprintSha256: input.fingerprint_sha256.trim(),
certificatePem: input.certificate_pem.trim(),
validFrom: input.valid_from,
validTo: input.valid_to,
caCertificateId: input.caCertificateId,
commonName: input.commonName.trim(),
serialNumber: input.serialNumber.trim(),
fingerprintSha256: input.fingerprintSha256.trim(),
certificatePem: input.certificatePem.trim(),
validFrom: input.validFrom,
validTo: input.validTo,
createdBy: actorUserId,
createdAt: now,
updatedAt: now
@@ -91,10 +91,10 @@ export async function createIssuedClientCertificate(
action: "create",
entityType: "issued_client_certificate",
entityId: record.id,
summary: `Issued client certificate ${input.common_name}`,
summary: `Issued client certificate ${input.commonName}`,
data: {
caCertificateId: input.ca_certificate_id,
serialNumber: input.serial_number
caCertificateId: input.caCertificateId,
serialNumber: input.serialNumber
}
});
await applyCaddyConfig();
@@ -109,7 +109,7 @@ export async function revokeIssuedClientCertificate(
if (!existing) {
throw new Error("Issued client certificate not found");
}
if (existing.revoked_at) {
if (existing.revokedAt) {
throw new Error("Issued client certificate is already revoked");
}
@@ -127,10 +127,10 @@ export async function revokeIssuedClientCertificate(
action: "revoke",
entityType: "issued_client_certificate",
entityId: id,
summary: `Revoked client certificate ${existing.common_name}`,
summary: `Revoked client certificate ${existing.commonName}`,
data: {
caCertificateId: existing.ca_certificate_id,
serialNumber: existing.serial_number
caCertificateId: existing.caCertificateId,
serialNumber: existing.serialNumber
}
});
await applyCaddyConfig();

View File

@@ -113,41 +113,41 @@ export type L4ProxyHost = {
id: number;
name: string;
protocol: L4Protocol;
listen_address: string;
listenAddress: string;
upstreams: string[];
matcher_type: L4MatcherType;
matcher_value: string[];
tls_termination: boolean;
proxy_protocol_version: L4ProxyProtocolVersion | null;
proxy_protocol_receive: boolean;
matcherType: L4MatcherType;
matcherValue: string[];
tlsTermination: boolean;
proxyProtocolVersion: L4ProxyProtocolVersion | null;
proxyProtocolReceive: boolean;
enabled: boolean;
meta: L4ProxyHostMeta | null;
load_balancer: L4LoadBalancerConfig | null;
dns_resolver: L4DnsResolverConfig | null;
upstream_dns_resolution: L4UpstreamDnsResolutionConfig | null;
loadBalancer: L4LoadBalancerConfig | null;
dnsResolver: L4DnsResolverConfig | null;
upstreamDnsResolution: L4UpstreamDnsResolutionConfig | null;
geoblock: L4GeoBlockConfig | null;
geoblock_mode: L4GeoBlockMode;
created_at: string;
updated_at: string;
geoblockMode: L4GeoBlockMode;
createdAt: string;
updatedAt: string;
};
export type L4ProxyHostInput = {
name: string;
protocol: L4Protocol;
listen_address: string;
listenAddress: string;
upstreams: string[];
matcher_type?: L4MatcherType;
matcher_value?: string[];
tls_termination?: boolean;
proxy_protocol_version?: L4ProxyProtocolVersion | null;
proxy_protocol_receive?: boolean;
matcherType?: L4MatcherType;
matcherValue?: string[];
tlsTermination?: boolean;
proxyProtocolVersion?: L4ProxyProtocolVersion | null;
proxyProtocolReceive?: boolean;
enabled?: boolean;
meta?: L4ProxyHostMeta | null;
load_balancer?: Partial<L4LoadBalancerConfig> | null;
dns_resolver?: Partial<L4DnsResolverConfig> | null;
upstream_dns_resolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
loadBalancer?: Partial<L4LoadBalancerConfig> | null;
dnsResolver?: Partial<L4DnsResolverConfig> | null;
upstreamDnsResolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
geoblock?: L4GeoBlockConfig | null;
geoblock_mode?: L4GeoBlockMode;
geoblockMode?: L4GeoBlockMode;
};
const VALID_PROTOCOLS: L4Protocol[] = ["tcp", "udp"];
@@ -363,22 +363,22 @@ function parseL4ProxyHost(row: L4ProxyHostRow): L4ProxyHost {
id: row.id,
name: row.name,
protocol: row.protocol as L4Protocol,
listen_address: row.listenAddress,
listenAddress: row.listenAddress,
upstreams: safeJsonParse<string[]>(row.upstreams, []),
matcher_type: (row.matcherType as L4MatcherType) || "none",
matcher_value: safeJsonParse<string[]>(row.matcherValue, []),
tls_termination: row.tlsTermination,
proxy_protocol_version: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
proxy_protocol_receive: row.proxyProtocolReceive,
matcherType: (row.matcherType as L4MatcherType) || "none",
matcherValue: safeJsonParse<string[]>(row.matcherValue, []),
tlsTermination: row.tlsTermination,
proxyProtocolVersion: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
proxyProtocolReceive: row.proxyProtocolReceive,
enabled: row.enabled,
meta: Object.keys(meta).length > 0 ? meta : null,
load_balancer: hydrateL4LoadBalancer(meta.load_balancer),
dns_resolver: hydrateL4DnsResolver(meta.dns_resolver),
upstream_dns_resolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
loadBalancer: hydrateL4LoadBalancer(meta.load_balancer),
dnsResolver: hydrateL4DnsResolver(meta.dns_resolver),
upstreamDnsResolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
geoblock: meta.geoblock?.enabled ? meta.geoblock : null,
geoblock_mode: meta.geoblock_mode ?? "merge",
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
geoblockMode: meta.geoblock_mode ?? "merge",
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!,
};
}
@@ -390,7 +390,7 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
if (!input.protocol || !VALID_PROTOCOLS.includes(input.protocol)) {
throw new Error("Protocol must be 'tcp' or 'udp'");
}
if (!input.listen_address?.trim()) {
if (!input.listenAddress?.trim()) {
throw new Error("Listen address is required");
}
if (!input.upstreams || input.upstreams.length === 0) {
@@ -398,8 +398,8 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
}
}
if (input.listen_address !== undefined) {
const addr = input.listen_address.trim();
if (input.listenAddress !== undefined) {
const addr = input.listenAddress.trim();
// Must be :PORT or HOST:PORT
const portMatch = addr.match(/:(\d+)$/);
if (!portMatch) {
@@ -415,22 +415,22 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
throw new Error("Protocol must be 'tcp' or 'udp'");
}
if (input.matcher_type !== undefined && !VALID_MATCHER_TYPES.includes(input.matcher_type)) {
if (input.matcherType !== undefined && !VALID_MATCHER_TYPES.includes(input.matcherType)) {
throw new Error(`Matcher type must be one of: ${VALID_MATCHER_TYPES.join(", ")}`);
}
if (input.matcher_type === "tls_sni" || input.matcher_type === "http_host") {
if (!input.matcher_value || input.matcher_value.length === 0) {
if (input.matcherType === "tls_sni" || input.matcherType === "http_host") {
if (!input.matcherValue || input.matcherValue.length === 0) {
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
}
}
if (input.tls_termination && input.protocol === "udp") {
if (input.tlsTermination && input.protocol === "udp") {
throw new Error("TLS termination is only supported with TCP protocol");
}
if (input.proxy_protocol_version !== undefined && input.proxy_protocol_version !== null) {
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxy_protocol_version)) {
if (input.proxyProtocolVersion !== undefined && input.proxyProtocolVersion !== null) {
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxyProtocolVersion)) {
throw new Error("Proxy protocol version must be 'v1' or 'v2'");
}
}
@@ -465,10 +465,10 @@ export async function countL4ProxyHosts(search?: string): Promise<number> {
const L4_SORT_COLUMNS: Record<string, any> = {
name: l4ProxyHosts.name,
protocol: l4ProxyHosts.protocol,
listen_address: l4ProxyHosts.listenAddress,
listenAddress: l4ProxyHosts.listenAddress,
upstreams: l4ProxyHosts.upstreams,
enabled: l4ProxyHosts.enabled,
created_at: l4ProxyHosts.createdAt,
createdAt: l4ProxyHosts.createdAt,
};
export async function listL4ProxyHostsPaginated(
@@ -506,21 +506,21 @@ export async function createL4ProxyHost(input: L4ProxyHostInput, actorUserId: nu
.values({
name: input.name.trim(),
protocol: input.protocol,
listenAddress: input.listen_address.trim(),
listenAddress: input.listenAddress.trim(),
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
matcherType: input.matcher_type ?? "none",
matcherValue: input.matcher_value ? JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) : null,
tlsTermination: input.tls_termination ?? false,
proxyProtocolVersion: input.proxy_protocol_version ?? null,
proxyProtocolReceive: input.proxy_protocol_receive ?? false,
matcherType: input.matcherType ?? "none",
matcherValue: input.matcherValue ? JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) : null,
tlsTermination: input.tlsTermination ?? false,
proxyProtocolVersion: input.proxyProtocolVersion ?? null,
proxyProtocolReceive: input.proxyProtocolReceive ?? false,
ownerUserId: actorUserId,
meta: (() => {
const meta: L4ProxyHostMeta = { ...(input.meta ?? {}) };
if (input.load_balancer) meta.load_balancer = dehydrateL4LoadBalancer(input.load_balancer);
if (input.dns_resolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dns_resolver);
if (input.upstream_dns_resolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
if (input.loadBalancer) meta.load_balancer = dehydrateL4LoadBalancer(input.loadBalancer);
if (input.dnsResolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dnsResolver);
if (input.upstreamDnsResolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
if (input.geoblock) meta.geoblock = input.geoblock;
if (input.geoblock_mode && input.geoblock_mode !== "merge") meta.geoblock_mode = input.geoblock_mode;
if (input.geoblockMode && input.geoblockMode !== "merge") meta.geoblock_mode = input.geoblockMode;
return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
})(),
enabled: input.enabled ?? true,
@@ -562,14 +562,14 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
// For validation, merge with existing to check cross-field constraints
const merged = {
protocol: input.protocol ?? existing.protocol,
tls_termination: input.tls_termination ?? existing.tls_termination,
matcher_type: input.matcher_type ?? existing.matcher_type,
matcher_value: input.matcher_value ?? existing.matcher_value,
tlsTermination: input.tlsTermination ?? existing.tlsTermination,
matcherType: input.matcherType ?? existing.matcherType,
matcherValue: input.matcherValue ?? existing.matcherValue,
};
if (merged.tls_termination && merged.protocol === "udp") {
if (merged.tlsTermination && merged.protocol === "udp") {
throw new Error("TLS termination is only supported with TCP protocol");
}
if ((merged.matcher_type === "tls_sni" || merged.matcher_type === "http_host") && merged.matcher_value.length === 0) {
if ((merged.matcherType === "tls_sni" || merged.matcherType === "http_host") && merged.matcherValue.length === 0) {
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
}
@@ -581,57 +581,57 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
.set({
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.protocol !== undefined ? { protocol: input.protocol } : {}),
...(input.listen_address !== undefined ? { listenAddress: input.listen_address.trim() } : {}),
...(input.listenAddress !== undefined ? { listenAddress: input.listenAddress.trim() } : {}),
...(input.upstreams !== undefined
? { upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))) }
: {}),
...(input.matcher_type !== undefined ? { matcherType: input.matcher_type } : {}),
...(input.matcher_value !== undefined
? { matcherValue: JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) }
...(input.matcherType !== undefined ? { matcherType: input.matcherType } : {}),
...(input.matcherValue !== undefined
? { matcherValue: JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) }
: {}),
...(input.tls_termination !== undefined ? { tlsTermination: input.tls_termination } : {}),
...(input.proxy_protocol_version !== undefined ? { proxyProtocolVersion: input.proxy_protocol_version } : {}),
...(input.proxy_protocol_receive !== undefined ? { proxyProtocolReceive: input.proxy_protocol_receive } : {}),
...(input.tlsTermination !== undefined ? { tlsTermination: input.tlsTermination } : {}),
...(input.proxyProtocolVersion !== undefined ? { proxyProtocolVersion: input.proxyProtocolVersion } : {}),
...(input.proxyProtocolReceive !== undefined ? { proxyProtocolReceive: input.proxyProtocolReceive } : {}),
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
...(() => {
const hasMetaChanges =
input.meta !== undefined ||
input.load_balancer !== undefined ||
input.dns_resolver !== undefined ||
input.upstream_dns_resolution !== undefined;
input.loadBalancer !== undefined ||
input.dnsResolver !== undefined ||
input.upstreamDnsResolution !== undefined;
if (!hasMetaChanges) return {};
// Start from existing meta
const existingMeta: L4ProxyHostMeta = {
...(existing.load_balancer ? { load_balancer: dehydrateL4LoadBalancer(existing.load_balancer) } : {}),
...(existing.dns_resolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dns_resolver) } : {}),
...(existing.upstream_dns_resolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstream_dns_resolution) } : {}),
...(existing.loadBalancer ? { load_balancer: dehydrateL4LoadBalancer(existing.loadBalancer) } : {}),
...(existing.dnsResolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dnsResolver) } : {}),
...(existing.upstreamDnsResolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstreamDnsResolution) } : {}),
...(existing.geoblock ? { geoblock: existing.geoblock } : {}),
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
};
// Apply direct meta override if provided
const meta: L4ProxyHostMeta = input.meta !== undefined ? { ...(input.meta ?? {}) } : { ...existingMeta };
// Apply structured field overrides
if (input.load_balancer !== undefined) {
const lb = dehydrateL4LoadBalancer(input.load_balancer);
if (input.loadBalancer !== undefined) {
const lb = dehydrateL4LoadBalancer(input.loadBalancer);
if (lb) {
meta.load_balancer = lb;
} else {
delete meta.load_balancer;
}
}
if (input.dns_resolver !== undefined) {
const dr = dehydrateL4DnsResolver(input.dns_resolver);
if (input.dnsResolver !== undefined) {
const dr = dehydrateL4DnsResolver(input.dnsResolver);
if (dr) {
meta.dns_resolver = dr;
} else {
delete meta.dns_resolver;
}
}
if (input.upstream_dns_resolution !== undefined) {
const udr = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
if (input.upstreamDnsResolution !== undefined) {
const udr = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
if (udr) {
meta.upstream_dns_resolution = udr;
} else {
@@ -645,9 +645,9 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
delete meta.geoblock;
}
}
if (input.geoblock_mode !== undefined) {
if (input.geoblock_mode !== "merge") {
meta.geoblock_mode = input.geoblock_mode;
if (input.geoblockMode !== undefined) {
if (input.geoblockMode !== "merge") {
meta.geoblock_mode = input.geoblockMode;
} else {
delete meta.geoblock_mode;
}

View File

@@ -8,23 +8,23 @@ import { asc, desc, eq, inArray } from "drizzle-orm";
export type MtlsAccessRule = {
id: number;
proxy_host_id: number;
path_pattern: string;
allowed_role_ids: number[];
allowed_cert_ids: number[];
deny_all: boolean;
proxyHostId: number;
pathPattern: string;
allowedRoleIds: number[];
allowedCertIds: number[];
denyAll: boolean;
priority: number;
description: string | null;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
export type MtlsAccessRuleInput = {
proxy_host_id: number;
path_pattern: string;
allowed_role_ids?: number[];
allowed_cert_ids?: number[];
deny_all?: boolean;
proxyHostId: number;
pathPattern: string;
allowedRoleIds?: number[];
allowedCertIds?: number[];
denyAll?: boolean;
priority?: number;
description?: string | null;
};
@@ -44,15 +44,15 @@ function parseJsonIds(raw: string): number[] {
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
return {
id: row.id,
proxy_host_id: row.proxyHostId,
path_pattern: row.pathPattern,
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
deny_all: row.denyAll,
proxyHostId: row.proxyHostId,
pathPattern: row.pathPattern,
allowedRoleIds: parseJsonIds(row.allowedRoleIds),
allowedCertIds: parseJsonIds(row.allowedCertIds),
denyAll: row.denyAll,
priority: row.priority,
description: row.description,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!,
};
}
@@ -82,11 +82,11 @@ export async function createMtlsAccessRule(
const [record] = await db
.insert(mtlsAccessRules)
.values({
proxyHostId: input.proxy_host_id,
pathPattern: input.path_pattern.trim(),
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
denyAll: input.deny_all ?? false,
proxyHostId: input.proxyHostId,
pathPattern: input.pathPattern.trim(),
allowedRoleIds: JSON.stringify(input.allowedRoleIds ?? []),
allowedCertIds: JSON.stringify(input.allowedCertIds ?? []),
denyAll: input.denyAll ?? false,
priority: input.priority ?? 0,
description: input.description ?? null,
createdBy: actorUserId,
@@ -102,7 +102,7 @@ export async function createMtlsAccessRule(
action: "create",
entityType: "mtls_access_rule",
entityId: record.id,
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
summary: `Created mTLS access rule for path ${input.pathPattern} on proxy host ${input.proxyHostId}`,
});
await applyCaddyConfig();
@@ -111,7 +111,7 @@ export async function createMtlsAccessRule(
export async function updateMtlsAccessRule(
id: number,
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
input: Partial<Omit<MtlsAccessRuleInput, "proxyHostId">>,
actorUserId: number
): Promise<MtlsAccessRule> {
const existing = await db.query.mtlsAccessRules.findFirst({
@@ -122,10 +122,10 @@ export async function updateMtlsAccessRule(
const now = nowIso();
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
if (input.pathPattern !== undefined) updates.pathPattern = input.pathPattern.trim();
if (input.allowedRoleIds !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowedRoleIds);
if (input.allowedCertIds !== undefined) updates.allowedCertIds = JSON.stringify(input.allowedCertIds);
if (input.denyAll !== undefined) updates.denyAll = input.denyAll;
if (input.priority !== undefined) updates.priority = input.priority;
if (input.description !== undefined) updates.description = input.description ?? null;
@@ -136,7 +136,7 @@ export async function updateMtlsAccessRule(
action: "update",
entityType: "mtls_access_rule",
entityId: id,
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
summary: `Updated mTLS access rule for path ${input.pathPattern ?? existing.pathPattern}`,
});
await applyCaddyConfig();
@@ -183,10 +183,10 @@ export async function getAccessRulesForHosts(
const map = new Map<number, MtlsAccessRule[]>();
for (const row of rows) {
const parsed = toMtlsAccessRule(row);
let bucket = map.get(parsed.proxy_host_id);
let bucket = map.get(parsed.proxyHostId);
if (!bucket) {
bucket = [];
map.set(parsed.proxy_host_id, bucket);
map.set(parsed.proxyHostId, bucket);
}
bucket.push(parsed);
}

View File

@@ -15,9 +15,9 @@ export type MtlsRole = {
id: number;
name: string;
description: string | null;
certificate_count: number;
created_at: string;
updated_at: string;
certificateCount: number;
createdAt: string;
updatedAt: string;
};
export type MtlsRoleInput = {
@@ -26,7 +26,7 @@ export type MtlsRoleInput = {
};
export type MtlsRoleWithCertificates = MtlsRole & {
certificate_ids: number[];
certificateIds: number[];
};
// ── Helpers ──────────────────────────────────────────────────────────
@@ -46,9 +46,9 @@ function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
id: row.id,
name: row.name,
description: row.description,
certificate_count: certCount,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
certificateCount: certCount,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!,
};
}
@@ -87,7 +87,7 @@ export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates
return {
...toMtlsRole(row, assignments.length),
certificate_ids: assignments.map((a) => a.certId),
certificateIds: assignments.map((a) => a.certId),
};
}

View File

@@ -0,0 +1,181 @@
import { randomUUID } from "node:crypto";
import db, { nowIso } from "../db";
import { oauthProviders } from "../db/schema";
import { eq, asc } from "drizzle-orm";
import { encryptSecret, decryptSecret } from "../secret";
export type OAuthProvider = {
id: string;
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer: string | null;
authorizationUrl: string | null;
tokenUrl: string | null;
userinfoUrl: string | null;
scopes: string;
autoLink: boolean;
enabled: boolean;
source: string;
createdAt: string;
updatedAt: string;
};
type DbProvider = typeof oauthProviders.$inferSelect;
function parseDbProvider(row: DbProvider): OAuthProvider {
return {
id: row.id,
name: row.name,
type: row.type,
clientId: decryptSecret(row.clientId),
clientSecret: decryptSecret(row.clientSecret),
issuer: row.issuer,
authorizationUrl: row.authorizationUrl,
tokenUrl: row.tokenUrl,
userinfoUrl: row.userinfoUrl,
scopes: row.scopes,
autoLink: row.autoLink,
enabled: row.enabled,
source: row.source,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function createOAuthProvider(data: {
name: string;
type?: string;
clientId: string;
clientSecret: string;
issuer?: string | null;
authorizationUrl?: string | null;
tokenUrl?: string | null;
userinfoUrl?: string | null;
scopes?: string;
autoLink?: boolean;
enabled?: boolean;
source?: string;
}): Promise<OAuthProvider> {
const now = nowIso();
const id = randomUUID();
const [row] = await db
.insert(oauthProviders)
.values({
id,
name: data.name,
type: data.type ?? "oidc",
clientId: encryptSecret(data.clientId),
clientSecret: encryptSecret(data.clientSecret),
issuer: data.issuer ?? null,
authorizationUrl: data.authorizationUrl ?? null,
tokenUrl: data.tokenUrl ?? null,
userinfoUrl: data.userinfoUrl ?? null,
scopes: data.scopes ?? "openid email profile",
autoLink: data.autoLink ?? false,
enabled: data.enabled ?? true,
source: data.source ?? "ui",
createdAt: now,
updatedAt: now,
})
.returning();
return parseDbProvider(row);
}
export async function listOAuthProviders(): Promise<OAuthProvider[]> {
const rows = await db.query.oauthProviders.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return rows.map(parseDbProvider);
}
export async function listEnabledOAuthProviders(): Promise<OAuthProvider[]> {
const rows = await db.query.oauthProviders.findMany({
where: (table, { eq }) => eq(table.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
});
return rows.map(parseDbProvider);
}
export async function getOAuthProvider(id: string): Promise<OAuthProvider | null> {
const row = await db.query.oauthProviders.findFirst({
where: (table, { eq }) => eq(table.id, id),
});
return row ? parseDbProvider(row) : null;
}
export async function getOAuthProviderByName(name: string): Promise<OAuthProvider | null> {
const row = await db.query.oauthProviders.findFirst({
where: (table, { eq }) => eq(table.name, name),
});
return row ? parseDbProvider(row) : null;
}
export async function updateOAuthProvider(
id: string,
data: Partial<{
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer: string | null;
authorizationUrl: string | null;
tokenUrl: string | null;
userinfoUrl: string | null;
scopes: string;
autoLink: boolean;
enabled: boolean;
}>
): Promise<OAuthProvider | null> {
const now = nowIso();
const updates: Record<string, unknown> = { updatedAt: now };
if (data.name !== undefined) updates.name = data.name;
if (data.type !== undefined) updates.type = data.type;
if (data.clientId !== undefined) updates.clientId = encryptSecret(data.clientId);
if (data.clientSecret !== undefined) updates.clientSecret = encryptSecret(data.clientSecret);
if (data.issuer !== undefined) updates.issuer = data.issuer;
if (data.authorizationUrl !== undefined) updates.authorizationUrl = data.authorizationUrl;
if (data.tokenUrl !== undefined) updates.tokenUrl = data.tokenUrl;
if (data.userinfoUrl !== undefined) updates.userinfoUrl = data.userinfoUrl;
if (data.scopes !== undefined) updates.scopes = data.scopes;
if (data.autoLink !== undefined) updates.autoLink = data.autoLink;
if (data.enabled !== undefined) updates.enabled = data.enabled;
const [row] = await db
.update(oauthProviders)
.set(updates)
.where(eq(oauthProviders.id, id))
.returning();
return row ? parseDbProvider(row) : null;
}
export async function deleteOAuthProvider(id: string): Promise<void> {
const row = await db.query.oauthProviders.findFirst({
where: (table, { eq }) => eq(table.id, id),
});
if (!row) {
throw new Error("OAuth provider not found");
}
if (row.source === "env") {
throw new Error("Cannot delete an environment-sourced OAuth provider");
}
await db.delete(oauthProviders).where(eq(oauthProviders.id, id));
}
export async function getProviderDisplayList(): Promise<Array<{ id: string; name: string }>> {
const rows = await db.query.oauthProviders.findMany({
where: (table, { eq }) => eq(table.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
columns: { id: true, name: true },
});
return rows.map((r) => ({ id: r.id, name: r.name }));
}

View File

@@ -279,60 +279,60 @@ export type ProxyHost = {
name: string;
domains: string[];
upstreams: string[];
certificate_id: number | null;
access_list_id: number | null;
ssl_forced: boolean;
hsts_enabled: boolean;
hsts_subdomains: boolean;
allow_websocket: boolean;
preserve_host_header: boolean;
skip_https_hostname_validation: boolean;
certificateId: number | null;
accessListId: number | null;
sslForced: boolean;
hstsEnabled: boolean;
hstsSubdomains: boolean;
allowWebsocket: boolean;
preserveHostHeader: boolean;
skipHttpsHostnameValidation: boolean;
enabled: boolean;
created_at: string;
updated_at: string;
custom_reverse_proxy_json: string | null;
custom_pre_handlers_json: string | null;
createdAt: string;
updatedAt: string;
customReverseProxyJson: string | null;
customPreHandlersJson: string | null;
authentik: ProxyHostAuthentikConfig | null;
load_balancer: LoadBalancerConfig | null;
dns_resolver: DnsResolverConfig | null;
upstream_dns_resolution: UpstreamDnsResolutionConfig | null;
loadBalancer: LoadBalancerConfig | null;
dnsResolver: DnsResolverConfig | null;
upstreamDnsResolution: UpstreamDnsResolutionConfig | null;
geoblock: GeoBlockSettings | null;
geoblock_mode: GeoBlockMode;
geoblockMode: GeoBlockMode;
waf: WafHostConfig | null;
mtls: MtlsConfig | null;
cpm_forward_auth: CpmForwardAuthConfig | null;
cpmForwardAuth: CpmForwardAuthConfig | null;
redirects: RedirectRule[];
rewrite: RewriteConfig | null;
location_rules: LocationRule[];
locationRules: LocationRule[];
};
export type ProxyHostInput = {
name: string;
domains: string[];
upstreams: string[];
certificate_id?: number | null;
access_list_id?: number | null;
ssl_forced?: boolean;
hsts_enabled?: boolean;
hsts_subdomains?: boolean;
allow_websocket?: boolean;
preserve_host_header?: boolean;
skip_https_hostname_validation?: boolean;
certificateId?: number | null;
accessListId?: number | null;
sslForced?: boolean;
hstsEnabled?: boolean;
hstsSubdomains?: boolean;
allowWebsocket?: boolean;
preserveHostHeader?: boolean;
skipHttpsHostnameValidation?: boolean;
enabled?: boolean;
custom_reverse_proxy_json?: string | null;
custom_pre_handlers_json?: string | null;
customReverseProxyJson?: string | null;
customPreHandlersJson?: string | null;
authentik?: ProxyHostAuthentikInput | null;
load_balancer?: LoadBalancerInput | null;
dns_resolver?: DnsResolverInput | null;
upstream_dns_resolution?: UpstreamDnsResolutionInput | null;
loadBalancer?: LoadBalancerInput | null;
dnsResolver?: DnsResolverInput | null;
upstreamDnsResolution?: UpstreamDnsResolutionInput | null;
geoblock?: GeoBlockSettings | null;
geoblock_mode?: GeoBlockMode;
geoblockMode?: GeoBlockMode;
waf?: WafHostConfig | null;
mtls?: MtlsConfig | null;
cpm_forward_auth?: CpmForwardAuthInput | null;
cpmForwardAuth?: CpmForwardAuthInput | null;
redirects?: RedirectRule[] | null;
rewrite?: RewriteConfig | null;
location_rules?: LocationRule[] | null;
locationRules?: LocationRule[] | null;
};
type ProxyHostRow = typeof proxyHosts.$inferSelect;
@@ -1105,8 +1105,8 @@ function normalizeUpstreamDnsResolutionInput(
function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): string | null {
const next: ProxyHostMeta = { ...existing };
if (input.custom_reverse_proxy_json !== undefined) {
const reverse = normalizeMetaValue(input.custom_reverse_proxy_json ?? null);
if (input.customReverseProxyJson !== undefined) {
const reverse = normalizeMetaValue(input.customReverseProxyJson ?? null);
if (reverse) {
next.custom_reverse_proxy_json = reverse;
} else {
@@ -1114,8 +1114,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.custom_pre_handlers_json !== undefined) {
const pre = normalizeMetaValue(input.custom_pre_handlers_json ?? null);
if (input.customPreHandlersJson !== undefined) {
const pre = normalizeMetaValue(input.customPreHandlersJson ?? null);
if (pre) {
next.custom_pre_handlers_json = pre;
} else {
@@ -1132,8 +1132,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.load_balancer !== undefined) {
const loadBalancer = normalizeLoadBalancerInput(input.load_balancer, existing.load_balancer);
if (input.loadBalancer !== undefined) {
const loadBalancer = normalizeLoadBalancerInput(input.loadBalancer, existing.load_balancer);
if (loadBalancer) {
next.load_balancer = loadBalancer;
} else {
@@ -1141,8 +1141,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.dns_resolver !== undefined) {
const dnsResolver = normalizeDnsResolverInput(input.dns_resolver, existing.dns_resolver);
if (input.dnsResolver !== undefined) {
const dnsResolver = normalizeDnsResolverInput(input.dnsResolver, existing.dns_resolver);
if (dnsResolver) {
next.dns_resolver = dnsResolver;
} else {
@@ -1150,9 +1150,9 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.upstream_dns_resolution !== undefined) {
if (input.upstreamDnsResolution !== undefined) {
const upstreamDnsResolution = normalizeUpstreamDnsResolutionInput(
input.upstream_dns_resolution,
input.upstreamDnsResolution,
existing.upstream_dns_resolution
);
if (upstreamDnsResolution) {
@@ -1172,8 +1172,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.geoblock_mode !== undefined) {
next.geoblock_mode = input.geoblock_mode;
if (input.geoblockMode !== undefined) {
next.geoblock_mode = input.geoblockMode;
}
if (input.waf !== undefined) {
@@ -1192,11 +1192,11 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.cpm_forward_auth !== undefined) {
if (input.cpm_forward_auth && input.cpm_forward_auth.enabled) {
if (input.cpmForwardAuth !== undefined) {
if (input.cpmForwardAuth && input.cpmForwardAuth.enabled) {
const cfa: CpmForwardAuthMeta = { enabled: true };
if (input.cpm_forward_auth.protected_paths && input.cpm_forward_auth.protected_paths.length > 0) {
cfa.protected_paths = input.cpm_forward_auth.protected_paths;
if (input.cpmForwardAuth.protected_paths && input.cpmForwardAuth.protected_paths.length > 0) {
cfa.protected_paths = input.cpmForwardAuth.protected_paths;
}
next.cpm_forward_auth = cfa;
} else {
@@ -1222,8 +1222,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
}
}
if (input.location_rules !== undefined) {
const rules = sanitizeLocationRules(input.location_rules ?? []);
if (input.locationRules !== undefined) {
const rules = sanitizeLocationRules(input.locationRules ?? []);
if (rules.length > 0) {
next.location_rules = rules;
} else {
@@ -1537,33 +1537,33 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
name: row.name,
domains: JSON.parse(row.domains),
upstreams: JSON.parse(row.upstreams),
certificate_id: row.certificateId ?? null,
access_list_id: row.accessListId ?? null,
ssl_forced: row.sslForced,
hsts_enabled: row.hstsEnabled,
hsts_subdomains: row.hstsSubdomains,
allow_websocket: row.allowWebsocket,
preserve_host_header: row.preserveHostHeader,
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
certificateId: row.certificateId ?? null,
accessListId: row.accessListId ?? null,
sslForced: row.sslForced,
hstsEnabled: row.hstsEnabled,
hstsSubdomains: row.hstsSubdomains,
allowWebsocket: row.allowWebsocket,
preserveHostHeader: row.preserveHostHeader,
skipHttpsHostnameValidation: row.skipHttpsHostnameValidation,
enabled: row.enabled,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!,
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
createdAt: toIso(row.createdAt)!,
updatedAt: toIso(row.updatedAt)!,
customReverseProxyJson: meta.custom_reverse_proxy_json ?? null,
customPreHandlersJson: meta.custom_pre_handlers_json ?? null,
authentik: hydrateAuthentik(meta.authentik),
load_balancer: hydrateLoadBalancer(meta.load_balancer),
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
upstream_dns_resolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
loadBalancer: hydrateLoadBalancer(meta.load_balancer),
dnsResolver: hydrateDnsResolver(meta.dns_resolver),
upstreamDnsResolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
geoblock: hydrateGeoBlock(meta.geoblock),
geoblock_mode: meta.geoblock_mode ?? "merge",
geoblockMode: meta.geoblock_mode ?? "merge",
waf: meta.waf ?? null,
mtls: meta.mtls ?? null,
cpm_forward_auth: meta.cpm_forward_auth?.enabled
cpmForwardAuth: meta.cpm_forward_auth?.enabled
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null }
: null,
redirects: meta.redirects ?? [],
rewrite: meta.rewrite ?? null,
location_rules: meta.location_rules ?? [],
locationRules: meta.location_rules ?? [],
};
}
@@ -1590,7 +1590,7 @@ const PROXY_HOST_SORT_COLUMNS: Record<string, any> = {
domains: proxyHosts.domains,
upstreams: proxyHosts.upstreams,
enabled: proxyHosts.enabled,
created_at: proxyHosts.createdAt,
createdAt: proxyHosts.createdAt,
};
export async function listProxyHostsPaginated(
@@ -1635,16 +1635,16 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
name: input.name.trim(),
domains: JSON.stringify(domains),
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
certificateId: input.certificate_id ?? null,
accessListId: input.access_list_id ?? null,
certificateId: input.certificateId ?? null,
accessListId: input.accessListId ?? null,
ownerUserId: actorUserId,
sslForced: input.ssl_forced ?? true,
hstsEnabled: input.hsts_enabled ?? true,
hstsSubdomains: input.hsts_subdomains ?? false,
allowWebsocket: input.allow_websocket ?? true,
preserveHostHeader: input.preserve_host_header ?? true,
sslForced: input.sslForced ?? true,
hstsEnabled: input.hstsEnabled ?? true,
hstsSubdomains: input.hstsSubdomains ?? false,
allowWebsocket: input.allowWebsocket ?? true,
preserveHostHeader: input.preserveHostHeader ?? true,
meta,
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? false,
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? false,
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now
@@ -1689,20 +1689,20 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
}
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
const existingMeta: ProxyHostMeta = {
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
custom_reverse_proxy_json: existing.customReverseProxyJson ?? undefined,
custom_pre_handlers_json: existing.customPreHandlersJson ?? undefined,
authentik: dehydrateAuthentik(existing.authentik),
load_balancer: dehydrateLoadBalancer(existing.load_balancer),
dns_resolver: dehydrateDnsResolver(existing.dns_resolver),
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstream_dns_resolution),
load_balancer: dehydrateLoadBalancer(existing.loadBalancer),
dns_resolver: dehydrateDnsResolver(existing.dnsResolver),
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstreamDnsResolution),
geoblock: dehydrateGeoBlock(existing.geoblock),
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
...(existing.waf ? { waf: existing.waf } : {}),
...(existing.mtls ? { mtls: existing.mtls } : {}),
...(existing.cpm_forward_auth?.enabled ? {
...(existing.cpmForwardAuth?.enabled ? {
cpm_forward_auth: {
enabled: true,
...(existing.cpm_forward_auth.protected_paths ? { protected_paths: existing.cpm_forward_auth.protected_paths } : {})
...(existing.cpmForwardAuth.protected_paths ? { protected_paths: existing.cpmForwardAuth.protected_paths } : {})
}
} : {}),
};
@@ -1715,15 +1715,15 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
name: input.name ?? existing.name,
domains,
upstreams,
certificateId: input.certificate_id !== undefined ? input.certificate_id : existing.certificate_id,
accessListId: input.access_list_id !== undefined ? input.access_list_id : existing.access_list_id,
sslForced: input.ssl_forced ?? existing.ssl_forced,
hstsEnabled: input.hsts_enabled ?? existing.hsts_enabled,
hstsSubdomains: input.hsts_subdomains ?? existing.hsts_subdomains,
allowWebsocket: input.allow_websocket ?? existing.allow_websocket,
preserveHostHeader: input.preserve_host_header ?? existing.preserve_host_header,
certificateId: input.certificateId !== undefined ? input.certificateId : existing.certificateId,
accessListId: input.accessListId !== undefined ? input.accessListId : existing.accessListId,
sslForced: input.sslForced ?? existing.sslForced,
hstsEnabled: input.hstsEnabled ?? existing.hstsEnabled,
hstsSubdomains: input.hstsSubdomains ?? existing.hstsSubdomains,
allowWebsocket: input.allowWebsocket ?? existing.allowWebsocket,
preserveHostHeader: input.preserveHostHeader ?? existing.preserveHostHeader,
meta,
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? existing.skipHttpsHostnameValidation,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
})

View File

@@ -1,5 +1,5 @@
import db, { nowIso, toIso } from "../db";
import { users } from "../db/schema";
import { users, accounts } from "../db/schema";
import { and, count, eq } from "drizzle-orm";
import { deleteUserForwardAuthSessions } from "./forward-auth";
@@ -7,14 +7,14 @@ export type User = {
id: number;
email: string;
name: string | null;
password_hash: string | null;
passwordHash: string | null;
role: "admin" | "user" | "viewer";
provider: string;
subject: string;
avatar_url: string | null;
provider: string | null;
subject: string | null;
avatarUrl: string | null;
status: string;
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
type DbUser = typeof users.$inferSelect;
@@ -24,14 +24,14 @@ function parseDbUser(user: DbUser): User {
id: user.id,
email: user.email,
name: user.name,
password_hash: user.passwordHash,
passwordHash: user.passwordHash,
role: user.role as "admin" | "user" | "viewer",
provider: user.provider,
subject: user.subject,
avatar_url: user.avatarUrl,
avatarUrl: user.avatarUrl,
status: user.status,
created_at: toIso(user.createdAt)!,
updated_at: toIso(user.updatedAt)!
createdAt: toIso(user.createdAt)!,
updatedAt: toIso(user.updatedAt)!
};
}
@@ -48,8 +48,14 @@ export async function getUserCount(): Promise<number> {
}
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
const account = await db.select().from(accounts).where(
and(eq(accounts.providerId, provider), eq(accounts.accountId, subject))
).limit(1);
if (account.length === 0) return null;
const user = await db.query.users.findFirst({
where: (table, operators) => and(operators.eq(table.provider, provider), operators.eq(table.subject, subject))
where: (table, { eq }) => eq(table.id, account[0].userId)
});
return user ? parseDbUser(user) : null;
}
@@ -68,7 +74,7 @@ export async function createUser(data: {
role?: User["role"];
provider: string;
subject: string;
avatar_url?: string | null;
avatarUrl?: string | null;
passwordHash?: string | null;
}): Promise<User> {
const now = nowIso();
@@ -84,7 +90,7 @@ export async function createUser(data: {
role,
provider: data.provider,
subject: data.subject,
avatarUrl: data.avatar_url ?? null,
avatarUrl: data.avatarUrl ?? null,
status: "active",
createdAt: now,
updatedAt: now
@@ -94,7 +100,7 @@ export async function createUser(data: {
return parseDbUser(user);
}
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): Promise<User | null> {
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatarUrl?: string | null }): Promise<User | null> {
const current = await getUserById(userId);
if (!current) {
return null;
@@ -106,7 +112,7 @@ export async function updateUserProfile(userId: number, data: { email?: string;
.set({
email: data.email ?? current.email,
name: data.name ?? current.name,
avatarUrl: data.avatar_url ?? current.avatar_url,
avatarUrl: data.avatarUrl ?? current.avatarUrl,
updatedAt: now
})
.where(eq(users.id, userId))

View File

@@ -1,11 +1,11 @@
import bcrypt from "bcryptjs";
import { randomBytes } from "crypto";
import { randomBytes, randomUUID } from "crypto";
import { SignJWT, jwtVerify } from "jose";
import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
import { findUserByEmail, getUserById } from "../models/user";
import db from "../db";
import { users, linkingTokens } from "../db/schema";
import { eq, lt } from "drizzle-orm";
import { users, linkingTokens, accounts } from "../db/schema";
import { and, eq, lt } from "drizzle-orm";
import { nowIso } from "../db";
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
@@ -32,14 +32,13 @@ export async function decideLinkingStrategy(
providerAccountId: string,
email: string
): Promise<LinkingDecision> {
// Check if OAuth account already exists
const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId);
if (existingOAuthUser) {
return {
action: "signin_existing",
userId: existingOAuthUser.id,
reason: "OAuth account already linked"
};
// Check accounts table for existing OAuth connection
const existingAccount = await db.select().from(accounts).where(
and(eq(accounts.providerId, provider), eq(accounts.accountId, providerAccountId))
).limit(1);
if (existingAccount.length > 0) {
return { action: "signin_existing", userId: existingAccount[0].userId, reason: "OAuth account already linked" };
}
// Check if email matches existing user
@@ -52,7 +51,7 @@ export async function decideLinkingStrategy(
}
// User exists with this email
if (existingEmailUser.password_hash) {
if (existingEmailUser.passwordHash) {
// Has password - require manual linking with password verification
return {
action: "require_manual_link",
@@ -188,25 +187,24 @@ export async function verifyAndLinkOAuth(
providerAccountId: string
): Promise<boolean> {
const user = await getUserById(userId);
if (!user || !user.password_hash) {
if (!user || !user.passwordHash) {
return false;
}
// Verify password
const isValid = bcrypt.compareSync(password, user.password_hash);
const isValid = bcrypt.compareSync(password, user.passwordHash);
if (!isValid) {
return false;
}
// Update user to link OAuth
await db
.update(users)
.set({
provider,
subject: providerAccountId,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
// Insert OAuth account link
await db.insert(accounts).values({
userId,
accountId: providerAccountId,
providerId: provider,
createdAt: nowIso(),
updatedAt: nowIso()
});
return true;
}
@@ -227,20 +225,26 @@ export async function autoLinkOAuth(
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
// This check is bypassed when called from the authenticated linking flow
if (user.password_hash && !config.oauth.allowAutoLinking) {
if (user.passwordHash && !config.oauth.allowAutoLinking) {
return false;
}
// Update user to link OAuth
await db
.update(users)
.set({
provider,
subject: providerAccountId,
avatarUrl: avatarUrl ?? user.avatar_url,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
// Insert OAuth account link
await db.insert(accounts).values({
userId,
accountId: providerAccountId,
providerId: provider,
createdAt: nowIso(),
updatedAt: nowIso()
});
// Update avatar if provided
if (avatarUrl) {
await db
.update(users)
.set({ avatarUrl, updatedAt: nowIso() })
.where(eq(users.id, userId));
}
return true;
}
@@ -260,16 +264,22 @@ export async function linkOAuthAuthenticated(
return false;
}
// Update user to link OAuth
await db
.update(users)
.set({
provider,
subject: providerAccountId,
avatarUrl: avatarUrl ?? user.avatar_url,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
// Insert OAuth account link
await db.insert(accounts).values({
userId,
accountId: providerAccountId,
providerId: provider,
createdAt: nowIso(),
updatedAt: nowIso()
});
// Update avatar if provided
if (avatarUrl) {
await db
.update(users)
.set({ avatarUrl, updatedAt: nowIso() })
.where(eq(users.id, userId));
}
return true;
}

View File

@@ -0,0 +1,44 @@
import { config } from "../config";
import {
getOAuthProviderByName,
createOAuthProvider,
updateOAuthProvider,
} from "../models/oauth-providers";
/**
* Sync OAUTH_* environment variables into the oauthProviders table.
* Env-sourced providers are created with source="env" and are read-only in the UI.
* Call this once at server startup.
*/
export async function syncEnvOAuthProviders(): Promise<void> {
if (
!config.oauth.enabled ||
!config.oauth.clientId ||
!config.oauth.clientSecret
) {
return;
}
const name = config.oauth.providerName;
const existing = await getOAuthProviderByName(name);
const data = {
type: "oidc" as const,
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
issuer: config.oauth.issuer ?? null,
authorizationUrl: config.oauth.authorizationUrl ?? null,
tokenUrl: config.oauth.tokenUrl ?? null,
userinfoUrl: config.oauth.userinfoUrl ?? null,
autoLink: config.oauth.allowAutoLinking,
};
if (existing && existing.source === "env") {
// Update existing env-sourced provider
await updateOAuthProvider(existing.id, { name, ...data });
} else if (!existing) {
// Create new env-sourced provider
await createOAuthProvider({ name, ...data, source: "env" });
}
// If a UI-sourced provider with the same name exists, don't overwrite it
}