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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
package-lock.json
.next
out
dist

View File

@@ -2,7 +2,7 @@
import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -45,7 +45,7 @@ export default function LinkAccountClient({
return;
}
await signIn(provider, { callbackUrl: "/" });
await authClient.signIn.social({ provider, callbackURL: "/" });
} catch {
setError("An error occurred while linking your account");
setLoading(false);

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { LogIn } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -36,28 +36,24 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
return;
}
const result = await signIn("credentials", {
redirect: false,
callbackUrl: "/",
const { data, error } = await authClient.signIn.username({
username,
password,
});
if (!result || result.error || result.ok === false) {
if (error) {
let message: string | null = null;
if (result?.status === 429) {
message = result.error && result.error !== "CredentialsSignin"
? result.error
: "Too many login attempts. Try again in a few minutes.";
} else if (result?.error && result.error !== "CredentialsSignin") {
message = result.error;
if (error.status === 429) {
message = error.message || "Too many login attempts. Try again in a few minutes.";
} else if (error.message) {
message = error.message;
}
setLoginError(message ?? "Invalid username or password.");
setLoginPending(false);
return;
}
router.replace(result.url ?? "/");
router.replace("/");
router.refresh();
};
@@ -65,7 +61,7 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
setLoginError(null);
setOauthPending(providerId);
try {
await signIn(providerId, { callbackUrl: "/" });
await authClient.signIn.social({ provider: providerId, callbackURL: "/" });
} catch {
setLoginError("Failed to sign in with OAuth");
setOauthPending(null);

View File

@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { auth } from "@/src/lib/auth";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import LoginClient from "./LoginClient";
export default async function LoginPage() {
@@ -9,7 +9,7 @@ export default async function LoginPage() {
redirect("/");
}
const enabledProviders = getEnabledOAuthProviders();
const enabledProviders = await getProviderDisplayList();
return <LoginClient enabledProviders={enabledProviders} />;
}

View File

@@ -2,7 +2,7 @@
import { FormEvent, useEffect, useState } from "react";
import { Shield } from "lucide-react";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -97,7 +97,7 @@ export default function PortalLoginForm({
// Redirect back to this portal page after OAuth, with the rid param preserved.
// The rid is an opaque server-side ID — the actual redirect URI is never in the URL.
const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`;
signIn(providerId, { callbackUrl });
authClient.signIn.social({ provider: providerId, callbackURL: callbackUrl });
};
const disabled = pending || !!oauthPending;

View File

@@ -1,5 +1,5 @@
import { auth } from "@/src/lib/auth";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import { isForwardAuthDomain, createRedirectIntent } from "@/src/lib/models/forward-auth";
import PortalLoginForm from "./PortalLoginForm";
@@ -36,7 +36,7 @@ export default async function PortalPage({ searchParams }: PortalPageProps) {
}
const session = await auth();
const enabledProviders = getEnabledOAuthProviders();
const enabledProviders = await getProviderDisplayList();
return (
<PortalLoginForm

View File

@@ -14,7 +14,7 @@ type StatCard = {
type RecentEvent = {
summary: string;
created_at: string;
createdAt: string;
};
type TrafficSummary = {
@@ -156,14 +156,14 @@ export default function OverviewClient({
<div className="absolute left-[28px] top-4 bottom-4 w-px bg-border" />
{recentEvents.map((event, index) => (
<div
key={`${event.created_at}-${index}`}
key={`${event.createdAt}-${index}`}
className="relative flex items-start gap-4 px-5 py-3 hover:bg-muted/30 transition-colors"
>
{/* Dot */}
<div className={`relative z-10 mt-1 h-3 w-3 shrink-0 rounded-full ${getEventDotColor(event.summary)}`} />
<span className="flex-1 text-sm leading-snug">{event.summary}</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{formatRelativeTime(event.created_at)}
{formatRelativeTime(event.createdAt)}
</span>
</div>
))}

View File

@@ -161,7 +161,7 @@ export default function AccessListsClient({ lists, pagination }: Props) {
<div>
<p className="text-sm font-medium font-mono leading-tight">{entry.username}</p>
<p className="text-xs text-muted-foreground">
Added {new Date(entry.created_at).toLocaleDateString()}
Added {new Date(entry.createdAt).toLocaleDateString()}
</p>
</div>
</div>

View File

@@ -10,7 +10,7 @@ import { PageHeader } from "@/components/ui/PageHeader";
type EventRow = {
id: number;
created_at: string;
createdAt: string;
user: string;
summary: string;
};
@@ -61,7 +61,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
width: 180,
render: (r: EventRow) => (
<span className="text-sm text-muted-foreground whitespace-nowrap">
{new Date(r.created_at).toLocaleString()}
{new Date(r.createdAt).toLocaleString()}
</span>
),
},
@@ -88,7 +88,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
<div className="flex justify-between items-center">
<Badge variant="outline">{r.user}</Badge>
<span className="text-xs text-muted-foreground">
{new Date(r.created_at).toLocaleString()}
{new Date(r.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm">{r.summary}</p>

View File

@@ -28,11 +28,11 @@ export default async function AuditLogPage({ searchParams }: PageProps) {
<AuditLogClient
events={events.map((event) => ({
id: event.id,
created_at: event.created_at,
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
user: event.user_id
? userMap.get(event.user_id)?.name ??
userMap.get(event.user_id)?.email ??
createdAt: event.createdAt,
summary: event.summary ?? `${event.action} on ${event.entityType}`,
user: event.userId
? userMap.get(event.userId)?.name ??
userMap.get(event.userId)?.email ??
"System"
: "System",
}))}

View File

@@ -23,10 +23,10 @@ export async function createCertificateAction(formData: FormData) {
{
name: String(formData.get("name") ?? "Certificate"),
type,
domain_names: parseDomains(formData.get("domain_names")),
auto_renew: type === "managed" ? formData.get("auto_renew") === "on" : false,
certificate_pem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
private_key_pem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
domainNames: parseDomains(formData.get("domain_names")),
autoRenew: type === "managed" ? formData.get("auto_renew") === "on" : false,
certificatePem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
privateKeyPem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
},
userId
);
@@ -42,10 +42,10 @@ export async function updateCertificateAction(id: number, formData: FormData) {
{
name: formData.get("name") ? String(formData.get("name")) : undefined,
type,
domain_names: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
auto_renew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
certificate_pem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
private_key_pem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
domainNames: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
autoRenew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
certificatePem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
privateKeyPem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
},
userId
);

View File

@@ -25,7 +25,7 @@ export async function createCaCertificateAction(formData: FormData) {
if (!certificatePem) throw new Error("Certificate PEM is required");
validatePem(certificatePem);
await createCaCertificate({ name, certificate_pem: certificatePem }, userId);
await createCaCertificate({ name, certificatePem: certificatePem }, userId);
revalidatePath("/certificates");
}
@@ -41,7 +41,7 @@ export async function updateCaCertificateAction(id: number, formData: FormData)
await updateCaCertificate(id, {
...(name ? { name } : {}),
...(certificatePem ? { certificate_pem: certificatePem } : {})
...(certificatePem ? { certificatePem: certificatePem } : {})
}, userId);
revalidatePath("/certificates");
}
@@ -92,7 +92,7 @@ export async function generateCaCertificateAction(formData: FormData): Promise<{
const certificatePem = forge.pki.certificateToPem(cert);
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
const record = await createCaCertificate({ name, certificate_pem: certificatePem, private_key_pem: privateKeyPem }, userId);
const record = await createCaCertificate({ name, certificatePem: certificatePem, privateKeyPem: privateKeyPem }, userId);
revalidatePath("/certificates");
return { id: record.id };
}
@@ -125,7 +125,7 @@ export async function issueClientCertificateAction(
if (!caCertRecord) throw new Error("CA certificate not found");
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificate_pem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificatePem);
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
@@ -149,13 +149,13 @@ export async function issueClientCertificateAction(
await createIssuedClientCertificate(
{
ca_certificate_id: caCertId,
common_name: commonName,
serial_number: cert.serialNumber.toUpperCase(),
fingerprint_sha256: certificate.fingerprint256,
certificate_pem: certificatePem,
valid_from: new Date(certificate.validFrom).toISOString(),
valid_to: new Date(certificate.validTo).toISOString()
caCertificateId: caCertId,
commonName: commonName,
serialNumber: cert.serialNumber.toUpperCase(),
fingerprintSha256: certificate.fingerprint256,
certificatePem: certificatePem,
validFrom: new Date(certificate.validFrom).toISOString(),
validTo: new Date(certificate.validTo).toISOString()
},
userId
);
@@ -184,5 +184,5 @@ export async function revokeIssuedClientCertificateAction(id: number): Promise<{
const userId = Number(session.user.id);
const record = await revokeIssuedClientCertificate(id, userId);
revalidatePath("/certificates");
return { revokedAt: record.revoked_at! };
return { revokedAt: record.revokedAt! };
}

View File

@@ -90,7 +90,7 @@ export function CaCertDrawer({ open, cert, onClose }: Props) {
id="edit-cert-pem"
name="certificate_pem"
required
defaultValue={cert.certificate_pem}
defaultValue={cert.certificatePem}
rows={8}
className="font-mono text-xs"
/>

View File

@@ -49,7 +49,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
const [issueCaOpen, setIssueCaOpen] = useState(false);
const [manageOpen, setManageOpen] = useState(false);
const active = ca.issuedCerts.filter((c) => !c.revoked_at);
const active = ca.issuedCerts.filter((c) => !c.revokedAt);
return (
<div className="px-5 py-4 bg-muted/30 border-t">
@@ -62,7 +62,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
</span>
</span>
<div className="flex gap-2">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setIssueCaOpen(true)}>
Issue Cert
</Button>
@@ -80,10 +80,10 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
) : (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{active.slice(0, 5).map((issued) => {
const expired = new Date(issued.valid_to).getTime() < Date.now();
const expired = new Date(issued.validTo).getTime() < Date.now();
return (
<div key={issued.id} className="flex items-center justify-between gap-2 px-3 py-2 bg-background/60">
<span className="text-sm font-mono">{issued.common_name}</span>
<span className="text-sm font-mono">{issued.commonName}</span>
<Badge variant={expired ? "destructive" : "success"} className="text-[10px] px-1.5 py-0">
{expired ? "Expired" : "Active"}
</Badge>
@@ -135,7 +135,7 @@ function CaActionsMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<DropdownMenuItem onClick={() => { setOpen(false); setIssuedOpen(true); }}>
Issue Client Cert
</DropdownMenuItem>
@@ -186,7 +186,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</Card>
) : (
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
return (
<Card key={ca.id} className="border-l-2 border-l-violet-500">
<CardContent className="p-4 flex flex-col gap-2">
@@ -200,7 +200,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
<CaActionsMenu ca={ca} onEdit={() => setDrawerCert(ca)} onDelete={() => setDeleteCert(ca)} />
</div>
<div className="flex flex-wrap gap-1.5">
{ca.has_private_key && (
{ca.hasPrivateKey && (
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Key stored
</Badge>
@@ -210,7 +210,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
{activeCount}/{ca.issuedCerts.length} active
</Badge>
)}
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
</div>
</CardContent>
</Card>
@@ -241,7 +241,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</TableRow>
) : (
filtered.map((ca) => {
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
const expanded = expandedId === ca.id;
return (
<React.Fragment key={ca.id}>
@@ -267,7 +267,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
</div>
</TableCell>
<TableCell>
{ca.has_private_key ? (
{ca.hasPrivateKey ? (
<Badge variant="success" className="text-[10px] px-1.5 py-0">
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Stored
</Badge>
@@ -285,7 +285,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
)}
</TableCell>
<TableCell>
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
</TableCell>
<TableCell className="text-right">
<CaActionsMenu

View File

@@ -254,7 +254,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
id: "domains",
label: "Domains",
render: (c: ManagedCertView) => (
<p className="text-sm text-muted-foreground font-mono">{c.domain_names.join(", ")}</p>
<p className="text-sm text-muted-foreground font-mono">{c.domainNames.join(", ")}</p>
),
},
{

View File

@@ -22,7 +22,7 @@ export type AcmeHost = {
id: number;
name: string;
domains: string[];
ssl_forced: boolean;
sslForced: boolean;
enabled: boolean;
};
@@ -37,7 +37,7 @@ export type ImportedCertView = {
usedBy: { id: number; name: string; domains: string[] }[];
};
export type ManagedCertView = { id: number; name: string; domain_names: string[] };
export type ManagedCertView = { id: number; name: string; domainNames: string[] };
const PER_PAGE = 25;
@@ -124,7 +124,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
id: r.id,
name: r.name,
domains: JSON.parse(r.domains) as string[],
ssl_forced: r.sslForced,
sslForced: r.sslForced,
enabled: r.enabled,
}));
@@ -143,9 +143,9 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const importedCerts: ImportedCertView[] = [];
const managedCerts: ManagedCertView[] = [];
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
const current = map.get(cert.ca_certificate_id) ?? [];
const current = map.get(cert.caCertificateId) ?? [];
current.push(cert);
map.set(cert.ca_certificate_id, current);
map.set(cert.caCertificateId, current);
return map;
}, new Map());
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
@@ -168,7 +168,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
usedBy: usageMap.get(cert.id) ?? [],
});
} else {
managedCerts.push({ id: cert.id, name: cert.name, domain_names: domainNames });
managedCerts.push({ id: cert.id, name: cert.name, domainNames: domainNames });
}
}

View File

@@ -17,10 +17,10 @@ import {
} from "./actions";
type GroupMember = {
user_id: number;
userId: number;
email: string;
name: string | null;
created_at: string;
createdAt: string;
};
type Group = {
@@ -28,8 +28,8 @@ type Group = {
name: string;
description: string | null;
members: GroupMember[];
created_at: string;
updated_at: string;
createdAt: string;
updatedAt: string;
};
type UserEntry = {
@@ -50,7 +50,7 @@ export default function GroupsClient({ groups, users }: Props) {
const [addMemberGroupId, setAddMemberGroupId] = useState<number | null>(null);
function getAvailableUsers(group: Group): UserEntry[] {
const memberIds = new Set(group.members.map((m) => m.user_id));
const memberIds = new Set(group.members.map((m) => m.userId));
return users.filter((u) => !memberIds.has(u.id));
}
@@ -204,7 +204,7 @@ export default function GroupsClient({ groups, users }: Props) {
<div className="space-y-1">
{group.members.map((member) => (
<div
key={member.user_id}
key={member.userId}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
>
<div className="flex items-center gap-2">
@@ -223,7 +223,7 @@ export default function GroupsClient({ groups, users }: Props) {
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
await removeGroupMemberAction(group.id, member.user_id);
await removeGroupMemberAction(group.id, member.userId);
router.refresh();
}}
title="Remove member"

View File

@@ -31,9 +31,9 @@ type Props = {
};
function formatMatcher(host: L4ProxyHost): string {
switch (host.matcher_type) {
case "tls_sni": return `SNI: ${host.matcher_value.join(", ")}`;
case "http_host": return `Host: ${host.matcher_value.join(", ")}`;
switch (host.matcherType) {
case "tls_sni": return `SNI: ${host.matcherValue.join(", ")}`;
case "http_host": return `Host: ${host.matcherValue.join(", ")}`;
case "proxy_protocol": return "Proxy Protocol";
default: return "None";
}
@@ -111,10 +111,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
{
id: "listen",
label: "Listen",
sortKey: "listen_address",
sortKey: "listenAddress",
render: (host: L4ProxyHost) => (
<span className="text-sm font-mono font-medium tabular-nums text-foreground/80">
{host.listen_address}
{host.listenAddress}
</span>
),
},
@@ -190,7 +190,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
<ProtocolBadge protocol={host.protocol} />
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{host.listen_address}
{host.listenAddress}
<span className="mx-1 text-muted-foreground"></span>
{host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
</p>

View File

@@ -178,17 +178,17 @@ export async function createL4ProxyHostAction(
const input: L4ProxyHostInput = {
name: String(formData.get("name") ?? "Untitled"),
protocol: parseProtocol(formData),
listen_address: String(formData.get("listen_address") ?? "").trim(),
listenAddress: String(formData.get("listen_address") ?? "").trim(),
upstreams: parseUpstreams(formData.get("upstreams")),
matcher_type: matcherType,
matcher_value: matcherValue,
tls_termination: parseCheckbox(formData.get("tls_termination")),
proxy_protocol_version: parseProxyProtocolVersion(formData),
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
matcherType: matcherType,
matcherValue: matcherValue,
tlsTermination: parseCheckbox(formData.get("tls_termination")),
proxyProtocolVersion: parseProxyProtocolVersion(formData),
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
enabled: parseCheckbox(formData.get("enabled")),
load_balancer: parseL4LoadBalancerConfig(formData),
dns_resolver: parseL4DnsResolverConfig(formData),
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
loadBalancer: parseL4LoadBalancerConfig(formData),
dnsResolver: parseL4DnsResolverConfig(formData),
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
...parseL4GeoBlockConfig(formData),
};
@@ -219,17 +219,17 @@ export async function updateL4ProxyHostAction(
const input: Partial<L4ProxyHostInput> = {
name: formData.get("name") ? String(formData.get("name")) : undefined,
protocol: parseProtocol(formData),
listen_address: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
listenAddress: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
matcher_type: matcherType,
matcher_value: matcherValue,
tls_termination: parseCheckbox(formData.get("tls_termination")),
proxy_protocol_version: parseProxyProtocolVersion(formData),
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
matcherType: matcherType,
matcherValue: matcherValue,
tlsTermination: parseCheckbox(formData.get("tls_termination")),
proxyProtocolVersion: parseProxyProtocolVersion(formData),
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
enabled: formData.has("enabled_present") ? parseCheckbox(formData.get("enabled")) : undefined,
load_balancer: parseL4LoadBalancerConfig(formData),
dns_resolver: parseL4DnsResolverConfig(formData),
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
loadBalancer: parseL4LoadBalancerConfig(formData),
dnsResolver: parseL4DnsResolverConfig(formData),
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
...parseL4GeoBlockConfig(formData),
};

View File

@@ -27,7 +27,7 @@ export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
hosts={hosts}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
/>
);
}

View File

@@ -82,7 +82,7 @@ export default async function OverviewPage() {
isAdmin={true}
recentEvents={recentEventsRaw.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entityType}`,
created_at: toIso(event.createdAt)!
createdAt: toIso(event.createdAt)!
}))}
/>
);

View File

@@ -17,7 +17,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { signIn } from "next-auth/react";
import { authClient } from "@/src/lib/auth-client";
import { Camera, Check, Clock, Copy, Key, Link, LogIn, Lock, Plus, Trash2, Unlink, User, AlertTriangle } from "lucide-react";
import type { ApiToken } from "@/lib/models/api-tokens";
import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions";
@@ -26,11 +26,11 @@ interface UserData {
id: number;
email: string;
name: string | null;
provider: string;
subject: string;
password_hash: string | null;
provider: string | null;
subject: string | null;
passwordHash: string | null;
role: string;
avatar_url: string | null;
avatarUrl: string | null;
}
interface ProfileClientProps {
@@ -48,11 +48,11 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatarUrl);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const hasPassword = !!user.password_hash;
const hasPassword = !!user.passwordHash;
const hasOAuth = user.provider !== "credentials";
const handlePasswordChange = async () => {
@@ -158,9 +158,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
}
// Now initiate OAuth flow
await signIn(providerId, {
callbackUrl: "/profile"
});
await authClient.signIn.social({ provider: providerId, callbackURL: "/profile" });
} catch {
setError("An error occurred while linking OAuth");
setLoading(false);
@@ -382,7 +380,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<div>
<p className="text-sm text-muted-foreground">Authentication Method</p>
<Badge variant={user.provider === "credentials" ? "secondary" : "default"}>
{getProviderName(user.provider)}
{getProviderName(user.provider ?? "")}
</Badge>
</div>
@@ -442,7 +440,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{hasOAuth ? (
<div>
<p className="text-sm text-muted-foreground mb-2">
Your account is linked to {getProviderName(user.provider)}
Your account is linked to {getProviderName(user.provider ?? "")}
</p>
{hasPassword ? (
@@ -523,7 +521,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{apiTokens.length > 0 && (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{apiTokens.map((token) => {
const expired = isExpired(token.expires_at);
const expired = isExpired(token.expiresAt);
return (
<div
key={token.id}
@@ -543,15 +541,15 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0">
<p className="text-xs text-muted-foreground">
Created {formatDate(token.created_at)}
Created {formatDate(token.createdAt)}
</p>
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
Used {formatDate(token.last_used_at)}
Used {formatDate(token.lastUsedAt)}
</p>
{token.expires_at && (
{token.expiresAt && (
<p className="text-xs text-muted-foreground">
{expired ? "Expired" : "Expires"} {formatDate(token.expires_at)}
{expired ? "Expired" : "Expires"} {formatDate(token.expiresAt)}
</p>
)}
</div>
@@ -656,7 +654,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<DialogHeader>
<DialogTitle>Unlink OAuth Account</DialogTitle>
<DialogDescription>
Are you sure you want to unlink your {getProviderName(user.provider)} account?
Are you sure you want to unlink your {getProviderName(user.provider ?? "")} account?
You will only be able to sign in with your username and password after this.
</DialogDescription>
</DialogHeader>

View File

@@ -1,6 +1,6 @@
import { requireUser } from "@/src/lib/auth";
import { getUserById } from "@/src/lib/models/user";
import { getEnabledOAuthProviders } from "@/src/lib/config";
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
import { listApiTokens } from "@/src/lib/models/api-tokens";
import ProfileClient from "./ProfileClient";
import { redirect } from "next/navigation";
@@ -15,7 +15,7 @@ export default async function ProfilePage() {
}
const [enabledProviders, apiTokens] = await Promise.all([
Promise.resolve(getEnabledOAuthProviders()),
getProviderDisplayList(),
listApiTokens(userId),
]);

View File

@@ -133,10 +133,10 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
label: "Features",
render: (host: ProxyHost) => {
const badges = [
host.certificate_id && (
host.certificateId && (
<Badge key="tls" variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>
),
host.access_list_id && (
host.accessListId && (
<Badge key="auth" variant="warning" className="text-[10px] px-1.5 py-0">
<Shield className="h-2.5 w-2.5 mr-0.5" />Auth
</Badge>
@@ -156,7 +156,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
<MapPin className="h-2.5 w-2.5 mr-0.5" />Geo
</Badge>
),
host.load_balancer?.enabled && (
host.loadBalancer?.enabled && (
<Badge key="lb" variant="secondary" className="text-[10px] px-1.5 py-0">
<Scale className="h-2.5 w-2.5 mr-0.5" />LB
</Badge>
@@ -244,7 +244,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
</p>
<div className="flex items-center gap-1.5 mt-1">
<StatusChip status={host.enabled ? "active" : "inactive"} />
{host.certificate_id && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
{host.certificateId && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">

View File

@@ -497,7 +497,7 @@ export async function createProxyHostAction(
const session = await requireAdmin();
const userId = Number(session.user.id);
// Parse certificate_id safely
// Parse certificateId safely
const parsedCertificateId = parseCertificateId(formData.get("certificate_id"));
// Validate certificate exists and get sanitized value
@@ -516,25 +516,25 @@ export async function createProxyHostAction(
name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")),
upstreams: parseUpstreams(formData.get("upstreams")),
certificate_id: certificateId,
access_list_id: parseAccessListId(formData.get("access_list_id")),
ssl_forced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
certificateId: certificateId,
accessListId: parseAccessListId(formData.get("access_list_id")),
sslForced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
hstsSubdomains: parseCheckbox(formData.get("hsts_subdomains")),
skipHttpsHostnameValidation: parseCheckbox(formData.get("skip_https_hostname_validation")),
enabled: parseCheckbox(formData.get("enabled")),
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")),
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
customPreHandlersJson: parseOptionalText(formData.get("custom_pre_handlers_json")),
customReverseProxyJson: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
loadBalancer: parseLoadBalancerConfig(formData),
dnsResolver: parseDnsResolverConfig(formData),
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: parseMtlsConfig(formData),
redirects: parseRedirectsConfig(formData),
rewrite: parseRewriteConfig(formData),
location_rules: parseLocationRulesConfig(formData),
locationRules: parseLocationRulesConfig(formData),
},
userId
);
@@ -542,7 +542,7 @@ export async function createProxyHostAction(
// Save forward auth access if CPM forward auth is enabled
const faUserIds = formData.getAll("cpm_fa_user_id").map((v) => Number(v)).filter((n) => n > 0);
const faGroupIds = formData.getAll("cpm_fa_group_id").map((v) => Number(v)).filter((n) => n > 0);
if (host.cpm_forward_auth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
if (host.cpmForwardAuth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
await setForwardAuthAccess(host.id, { userIds: faUserIds, groupIds: faGroupIds }, userId);
}
@@ -597,30 +597,30 @@ export async function updateProxyHostAction(
name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
certificate_id: certificateId,
access_list_id: formData.has("access_list_id")
certificateId: certificateId,
accessListId: formData.has("access_list_id")
? parseAccessListId(formData.get("access_list_id"))
: undefined,
hsts_subdomains: boolField("hsts_subdomains"),
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
hstsSubdomains: boolField("hsts_subdomains"),
skipHttpsHostnameValidation: boolField("skip_https_hostname_validation"),
enabled: boolField("enabled"),
custom_pre_handlers_json: formData.has("custom_pre_handlers_json")
customPreHandlersJson: formData.has("custom_pre_handlers_json")
? parseOptionalText(formData.get("custom_pre_handlers_json"))
: undefined,
custom_reverse_proxy_json: formData.has("custom_reverse_proxy_json")
customReverseProxyJson: formData.has("custom_reverse_proxy_json")
? parseOptionalText(formData.get("custom_reverse_proxy_json"))
: undefined,
authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
loadBalancer: parseLoadBalancerConfig(formData),
dnsResolver: parseDnsResolverConfig(formData),
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData),
...parseWafConfig(formData),
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined,
redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined,
rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(formData) : undefined,
location_rules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
locationRules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
},
userId
);

View File

@@ -43,7 +43,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
]);
// Build forward auth access map for hosts that have CPM forward auth enabled
const faHosts = hosts.filter((h) => h.cpm_forward_auth?.enabled);
const faHosts = hosts.filter((h) => h.cpmForwardAuth?.enabled);
const faAccessEntries = await Promise.all(
faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => []))
);
@@ -51,8 +51,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
faHosts.forEach((h, i) => {
const entries = faAccessEntries[i];
forwardAuthAccessMap[h.id] = {
userIds: entries.filter((e) => e.user_id !== null).map((e) => e.user_id!),
groupIds: entries.filter((e) => e.group_id !== null).map((e) => e.group_id!),
userIds: entries.filter((e) => e.userId !== null).map((e) => e.userId!),
groupIds: entries.filter((e) => e.groupId !== null).map((e) => e.groupId!),
};
});
@@ -78,7 +78,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
authentikDefaults={authentikDefaults}
pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts}
forwardAuthUsers={forwardAuthUsers}

View File

@@ -0,0 +1,464 @@
"use client";
import { useState, useCallback } from "react";
import { Copy, Pencil, Plus, Trash2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import {
createOAuthProviderAction,
updateOAuthProviderAction,
deleteOAuthProviderAction,
} from "./actions";
interface OAuthProvidersSectionProps {
initialProviders: OAuthProvider[];
baseUrl: string;
}
type FormData = {
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer: string;
authorizationUrl: string;
tokenUrl: string;
userinfoUrl: string;
scopes: string;
autoLink: boolean;
};
const emptyForm: FormData = {
name: "",
type: "oidc",
clientId: "",
clientSecret: "",
issuer: "",
authorizationUrl: "",
tokenUrl: "",
userinfoUrl: "",
scopes: "openid email profile",
autoLink: false,
};
export default function OAuthProvidersSection({ initialProviders, baseUrl }: OAuthProvidersSectionProps) {
const [providers, setProviders] = useState(initialProviders);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<OAuthProvider | null>(null);
const [form, setForm] = useState<FormData>(emptyForm);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const callbackUrl = useCallback(
(providerId: string) => `${baseUrl}/api/auth/oauth2/callback/${providerId}`,
[baseUrl]
);
function openAddDialog() {
setEditingProvider(null);
setForm(emptyForm);
setError(null);
setDialogOpen(true);
}
function openEditDialog(provider: OAuthProvider) {
setEditingProvider(provider);
setForm({
name: provider.name,
type: provider.type,
clientId: provider.clientId,
clientSecret: provider.clientSecret,
issuer: provider.issuer ?? "",
authorizationUrl: provider.authorizationUrl ?? "",
tokenUrl: provider.tokenUrl ?? "",
userinfoUrl: provider.userinfoUrl ?? "",
scopes: provider.scopes,
autoLink: provider.autoLink,
});
setError(null);
setDialogOpen(true);
}
async function handleSave() {
if (!form.name.trim() || !form.clientId.trim() || !form.clientSecret.trim()) {
setError("Name, Client ID, and Client Secret are required.");
return;
}
setSaving(true);
setError(null);
try {
if (editingProvider) {
const updated = await updateOAuthProviderAction(editingProvider.id, {
name: form.name.trim(),
type: form.type,
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
issuer: form.issuer.trim() || null,
authorizationUrl: form.authorizationUrl.trim() || null,
tokenUrl: form.tokenUrl.trim() || null,
userinfoUrl: form.userinfoUrl.trim() || null,
scopes: form.scopes.trim() || "openid email profile",
autoLink: form.autoLink,
});
if (updated) {
setProviders((prev) =>
prev.map((p) => (p.id === editingProvider.id ? updated : p))
);
}
} else {
const created = await createOAuthProviderAction({
name: form.name.trim(),
type: form.type,
clientId: form.clientId.trim(),
clientSecret: form.clientSecret.trim(),
issuer: form.issuer.trim() || undefined,
authorizationUrl: form.authorizationUrl.trim() || undefined,
tokenUrl: form.tokenUrl.trim() || undefined,
userinfoUrl: form.userinfoUrl.trim() || undefined,
scopes: form.scopes.trim() || undefined,
autoLink: form.autoLink,
});
setProviders((prev) => [...prev, created]);
}
setDialogOpen(false);
} catch (err) {
setError(err instanceof Error ? err.message : "An error occurred");
} finally {
setSaving(false);
}
}
async function handleToggleEnabled(provider: OAuthProvider) {
try {
const updated = await updateOAuthProviderAction(provider.id, {
enabled: !provider.enabled,
});
if (updated) {
setProviders((prev) =>
prev.map((p) => (p.id === provider.id ? updated : p))
);
}
} catch (err) {
console.error("Failed to toggle provider:", err);
}
}
async function handleDelete(id: string) {
try {
await deleteOAuthProviderAction(id);
setProviders((prev) => prev.filter((p) => p.id !== id));
setDeleteConfirmId(null);
} catch (err) {
console.error("Failed to delete provider:", err);
}
}
function copyToClipboard(text: string, providerId: string) {
navigator.clipboard.writeText(text).then(() => {
setCopiedId(providerId);
setTimeout(() => setCopiedId(null), 2000);
});
}
function updateField<K extends keyof FormData>(field: K, value: FormData[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
return (
<div className="flex flex-col gap-3">
{providers.length === 0 && (
<Alert className="border-blue-500/30 bg-blue-500/5 text-blue-700 dark:text-blue-400 [&>svg]:text-blue-500">
<AlertDescription>
No OAuth providers configured. Add a provider to enable single sign-on.
</AlertDescription>
</Alert>
)}
{providers.map((provider) => (
<div
key={provider.id}
className="flex flex-col gap-2 rounded-md border px-4 py-3"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-sm font-semibold">{provider.name}</p>
<Badge variant="muted">{provider.type.toUpperCase()}</Badge>
<Badge variant={provider.source === "env" ? "info" : "secondary"}>
{provider.source === "env" ? "ENV" : "UI"}
</Badge>
{!provider.enabled && (
<Badge variant="warning">Disabled</Badge>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor={`toggle-${provider.id}`} className="text-xs text-muted-foreground">
Enabled
</Label>
<Switch
id={`toggle-${provider.id}`}
checked={provider.enabled}
onCheckedChange={() => handleToggleEnabled(provider)}
/>
</div>
<Button
variant="outline"
size="sm"
onClick={() => openEditDialog(provider)}
disabled={provider.source === "env"}
title={provider.source === "env" ? "Environment-sourced providers cannot be edited" : "Edit provider"}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{deleteConfirmId === provider.id ? (
<div className="flex items-center gap-1">
<Button
variant="destructive"
size="sm"
onClick={() => handleDelete(provider.id)}
>
Confirm
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteConfirmId(null)}
>
Cancel
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/50"
onClick={() => setDeleteConfirmId(provider.id)}
disabled={provider.source === "env"}
title={provider.source === "env" ? "Environment-sourced providers cannot be deleted" : "Delete provider"}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-muted-foreground break-all">
{callbackUrl(provider.id)}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => copyToClipboard(callbackUrl(provider.id), provider.id)}
title="Copy callback URL"
>
<Copy className="h-3 w-3" />
</Button>
{copiedId === provider.id && (
<span className="text-xs text-emerald-600">Copied!</span>
)}
</div>
</div>
))}
<div className="flex justify-end">
<Button size="sm" onClick={openAddDialog}>
<Plus className="h-4 w-4 mr-1" />
Add Provider
</Button>
</div>
{/* Add / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingProvider ? "Edit OAuth Provider" : "Add OAuth Provider"}
</DialogTitle>
<DialogDescription>
{editingProvider
? "Update the OAuth provider configuration."
: "Configure a new OAuth or OIDC provider for single sign-on."}
</DialogDescription>
</DialogHeader>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-name">Name *</Label>
<Input
id="oauth-name"
value={form.name}
onChange={(e) => updateField("name", e.target.value)}
placeholder="e.g. Google, Keycloak"
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-type">Type</Label>
<Select
value={form.type}
onValueChange={(v) => updateField("type", v)}
>
<SelectTrigger id="oauth-type" className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="oidc">OIDC (OpenID Connect)</SelectItem>
<SelectItem value="oauth2">OAuth2</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-client-id">Client ID *</Label>
<Input
id="oauth-client-id"
value={form.clientId}
onChange={(e) => updateField("clientId", e.target.value)}
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-client-secret">Client Secret *</Label>
<Input
id="oauth-client-secret"
type="password"
autoComplete="new-password"
value={form.clientSecret}
onChange={(e) => updateField("clientSecret", e.target.value)}
className="h-8 text-sm"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-issuer">Issuer URL</Label>
<Input
id="oauth-issuer"
value={form.issuer}
onChange={(e) => updateField("issuer", e.target.value)}
placeholder="https://accounts.google.com"
className="h-8 text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">
For OIDC providers, the issuer URL enables automatic discovery of endpoints.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-auth-url">Authorization URL</Label>
<Input
id="oauth-auth-url"
value={form.authorizationUrl}
onChange={(e) => updateField("authorizationUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-token-url">Token URL</Label>
<Input
id="oauth-token-url"
value={form.tokenUrl}
onChange={(e) => updateField("tokenUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-userinfo-url">Userinfo URL</Label>
<Input
id="oauth-userinfo-url"
value={form.userinfoUrl}
onChange={(e) => updateField("userinfoUrl", e.target.value)}
placeholder="Override discovered endpoint"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="oauth-scopes">Scopes</Label>
<Input
id="oauth-scopes"
value={form.scopes}
onChange={(e) => updateField("scopes", e.target.value)}
placeholder="openid email profile"
className="h-8 text-sm font-mono"
/>
</div>
<div className="flex items-center gap-2 pt-1">
<Switch
id="oauth-auto-link"
checked={form.autoLink}
onCheckedChange={(v) => updateField("autoLink", v)}
/>
<Label htmlFor="oauth-auto-link">
Auto-link accounts
</Label>
</div>
<p className="text-xs text-muted-foreground -mt-1">
Automatically link OAuth accounts to existing users with the same email address.
</p>
{editingProvider && (
<div className="flex flex-col gap-1.5 pt-1">
<Label className="text-xs text-muted-foreground">Callback URL</Label>
<div className="flex items-center gap-2">
<code className="text-xs font-mono text-muted-foreground break-all">
{callbackUrl(editingProvider.id)}
</code>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 shrink-0"
onClick={() => copyToClipboard(callbackUrl(editingProvider.id), editingProvider.id)}
title="Copy callback URL"
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : editingProvider ? "Update Provider" : "Create Provider"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { useFormState } from "react-dom";
import {
Cloud, Globe, Network, Pin, Activity,
ScrollText, Settings2, UserCheck, MapPin,
ScrollText, Settings2, UserCheck, MapPin, KeyRound,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
@@ -24,6 +24,8 @@ import type {
GeoBlockSettings,
} from "@/lib/settings";
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import OAuthProvidersSection from "./OAuthProvidersSection";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import {
updateCloudflareSettingsAction,
updateGeneralSettingsAction,
@@ -117,6 +119,7 @@ const A: Record<string, AccentConfig> = {
metrics: { border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500" },
logging: { border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500" },
geoblock: { border: "border-l-teal-500", icon: "border-teal-500/30 bg-teal-500/10 text-teal-500" },
oauth: { border: "border-l-indigo-500", icon: "border-indigo-500/30 bg-indigo-500/10 text-indigo-500" },
};
// ─── Props ────────────────────────────────────────────────────────────────────
@@ -134,6 +137,8 @@ type Props = {
dns: DnsSettings | null;
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
globalGeoBlock?: GeoBlockSettings | null;
oauthProviders: OAuthProvider[];
baseUrl: string;
instanceSync: {
mode: "standalone" | "master" | "slave";
modeFromEnv: boolean;
@@ -156,10 +161,10 @@ type Props = {
instances: Array<{
id: number;
name: string;
base_url: string;
baseUrl: string;
enabled: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
lastSyncAt: string | null;
lastSyncError: string | null;
}>;
envInstances: Array<{
name: string;
@@ -180,6 +185,8 @@ export default function SettingsClient({
dns,
upstreamDnsResolution,
globalGeoBlock,
oauthProviders,
baseUrl,
instanceSync
}: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
@@ -376,12 +383,12 @@ export default function SettingsClient({
>
<div>
<p className="text-sm font-semibold">{instance.name}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.base_url}</p>
<p className="text-xs text-muted-foreground font-mono">{instance.baseUrl}</p>
<span className="text-xs text-muted-foreground">
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
{instance.lastSyncAt ? `Last sync: ${instance.lastSyncAt}` : "No sync yet"}
</span>
{instance.last_sync_error && (
<span className="block text-xs text-destructive">{instance.last_sync_error}</span>
{instance.lastSyncError && (
<span className="block text-xs text-destructive">{instance.lastSyncError}</span>
)}
</div>
<div className="flex gap-2">
@@ -853,6 +860,16 @@ export default function SettingsClient({
</div>
</form>
</SettingSection>
{/* ── OAuth Providers ── */}
<SettingSection
icon={<KeyRound className="h-4 w-4" />}
title="OAuth Providers"
description="Configure OAuth/OIDC providers for single sign-on. Users can log in via these providers in addition to local credentials."
accent={A.oauth}
>
<OAuthProvidersSection initialProviders={oauthProviders} baseUrl={baseUrl} />
</SettingSection>
</div>
);
}

View File

@@ -676,6 +676,76 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise<Act
}
}
export async function getOAuthProvidersAction() {
await requireAdmin();
const { listOAuthProviders } = await import("@/src/lib/models/oauth-providers");
return listOAuthProviders();
}
export async function createOAuthProviderAction(data: {
name: string;
type: string;
clientId: string;
clientSecret: string;
issuer?: string;
authorizationUrl?: string;
tokenUrl?: string;
userinfoUrl?: string;
scopes?: string;
autoLink?: boolean;
}) {
const session = await requireAdmin();
const { createOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
const provider = await createOAuthProvider({ ...data, source: "ui" });
invalidateProviderCache();
const { createAuditEvent } = await import("@/src/lib/models/audit");
await createAuditEvent({
userId: Number(session.user.id),
action: "oauth_provider_created",
entityType: "oauth_provider",
entityId: null,
summary: `OAuth provider "${data.name}" created`,
data: JSON.stringify({ providerId: provider.id }),
});
revalidatePath("/settings");
return provider;
}
export async function updateOAuthProviderAction(
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;
}>
) {
await requireAdmin();
const { updateOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
const updated = await updateOAuthProvider(id, data);
invalidateProviderCache();
revalidatePath("/settings");
return updated;
}
export async function deleteOAuthProviderAction(id: string) {
await requireAdmin();
const { deleteOAuthProvider } = await import("@/src/lib/models/oauth-providers");
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
await deleteOAuthProvider(id);
invalidateProviderCache();
revalidatePath("/settings");
}
export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise<ActionResult> {
try {
const session = await requireAdmin();

View File

@@ -2,6 +2,8 @@ import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
import { listInstances } from "@/src/lib/models/instances";
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
import { config } from "@/src/lib/config";
import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() {
@@ -11,7 +13,7 @@ export default async function SettingsPage() {
const modeFromEnv = isInstanceModeFromEnv();
const tokenFromEnv = isSyncTokenFromEnv();
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock] = await Promise.all([
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([
getGeneralSettings(),
getCloudflareSettings(),
getAuthentikSettings(),
@@ -21,6 +23,7 @@ export default async function SettingsPage() {
getUpstreamDnsResolutionSettings(),
getInstanceMode(),
getGeoBlockSettings(),
listOAuthProviders(),
]);
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
@@ -57,6 +60,8 @@ export default async function SettingsPage() {
dns={dns}
upstreamDnsResolution={upstreamDnsResolution}
globalGeoBlock={globalGeoBlock}
oauthProviders={oauthProviders}
baseUrl={config.baseUrl}
instanceSync={{
mode: instanceMode,
modeFromEnv,

View File

@@ -22,12 +22,12 @@ type UserEntry = {
email: string;
name: 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 Props = {

View File

@@ -7,6 +7,6 @@ export default async function UsersPage() {
const allUsers = await listUsers();
// Strip password hashes before sending to client
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const safeUsers = allUsers.map(({ password_hash, ...rest }) => rest);
const safeUsers = allUsers.map(({ passwordHash, ...rest }) => rest);
return <UsersClient users={safeUsers} />;
}

View File

@@ -0,0 +1,12 @@
import { getAuth } from "@/src/lib/auth-server";
import { toNextJsHandler } from "better-auth/next-js";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
return toNextJsHandler(getAuth()).GET(request);
}
export async function POST(request: Request) {
return toNextJsHandler(getAuth()).POST(request);
}

View File

@@ -1,97 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { handlers } from "@/src/lib/auth";
import { isRateLimited, registerFailedAttempt, resetAttempts } from "@/src/lib/rate-limit";
export const dynamic = 'force-dynamic';
export const { GET } = handlers;
function getClientIp(request: NextRequest): string {
// Get client IP from headers
// In production, ensure your reverse proxy (Caddy) sets these headers correctly
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) {
const parts = forwarded.split(",");
return parts[parts.length - 1]?.trim() || "unknown";
}
const real = request.headers.get("x-real-ip");
if (real) {
return real.trim();
}
return "unknown";
}
function buildRateLimitKey(ip: string, username: string) {
const normalizedUsername = username.trim().toLowerCase() || "unknown";
return `login:${ip}:${normalizedUsername}`;
}
function buildBlockedResponse(retryAfterMs?: number) {
const retryAfterSeconds = retryAfterMs ? Math.ceil(retryAfterMs / 1000) : 60;
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterSeconds / 60));
return NextResponse.json(
{
error: `Too many login attempts. Try again in about ${retryAfterMinutes} minute${retryAfterMinutes === 1 ? "" : "s"}.`
},
{
status: 429,
headers: {
"Retry-After": retryAfterSeconds.toString()
}
}
);
}
export async function POST(request: NextRequest) {
const formData = await request.clone().formData();
const username = String(formData.get("username") ?? "");
const ip = getClientIp(request);
const rateLimitKey = buildRateLimitKey(ip, username);
const limitation = isRateLimited(rateLimitKey);
if (limitation.blocked) {
return buildBlockedResponse(limitation.retryAfterMs);
}
const response = await handlers.POST(request);
// Determine success/failure by inspecting redirect destination, not status code.
// Auth.js returns 302 (direct form) or 200+JSON (X-Auth-Return-Redirect) on both
// success and failure — the error is signaled by the destination URL containing "error=".
const isFailure = await isAuthFailureResponse(response);
if (isFailure) {
const result = registerFailedAttempt(rateLimitKey);
if (result.blocked) {
return buildBlockedResponse(result.retryAfterMs);
}
} else {
resetAttempts(rateLimitKey);
}
return response;
}
async function isAuthFailureResponse(response: Response): Promise<boolean> {
// Redirect case: Auth.js sets Location header
const location = response.headers.get("location");
if (location) {
return location.includes("error=");
}
// JSON case (X-Auth-Return-Redirect: 1): body is {"url": "..."}
const contentType = response.headers.get("content-type") ?? "";
if (response.status === 200 && contentType.includes("application/json")) {
try {
const cloned = response.clone();
const body = await cloned.json() as { url?: string };
if (typeof body.url === "string") {
return body.url.includes("error=");
}
} catch {
// ignore parse errors
}
}
// Any 4xx/5xx is a failure
return response.status >= 400;
}

View File

@@ -1,10 +1,14 @@
import { NextRequest } from "next/server";
import { signOut, checkSameOrigin } from "@/src/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { getAuth } from "@/src/lib/auth-server";
import { checkSameOrigin } from "@/src/lib/auth";
import { headers } from "next/headers";
export const dynamic = 'force-dynamic';
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
const originCheck = checkSameOrigin(request);
if (originCheck) return originCheck;
await signOut({ redirectTo: "/login" });
await getAuth().api.signOut({ headers: await headers() });
return NextResponse.redirect(new URL("/login", request.url));
}

View File

@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
}
// If user has a password, verify current password
if (user.password_hash) {
if (user.passwordHash) {
if (!currentPassword) {
return NextResponse.json(
{ error: "Current password is required" },
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
);
}
const isValid = bcrypt.compareSync(currentPassword, user.password_hash);
const isValid = bcrypt.compareSync(currentPassword, user.passwordHash);
if (!isValid) {
registerFailedAttempt(rateLimitKey);
return NextResponse.json(
@@ -90,10 +90,10 @@ export async function POST(request: NextRequest) {
// Audit log
await createAuditEvent({
userId,
action: user.password_hash ? "password_changed" : "password_set",
action: user.passwordHash ? "password_changed" : "password_set",
entityType: "user",
entityId: userId,
summary: user.password_hash ? "User changed their password" : "User set a password",
summary: user.passwordHash ? "User changed their password" : "User set a password",
});
return NextResponse.json({

View File

@@ -3,9 +3,8 @@ import { auth, checkSameOrigin } from "@/src/lib/auth";
import { getUserById } from "@/src/lib/models/user";
import { createAuditEvent } from "@/src/lib/models/audit";
import db from "@/src/lib/db";
import { users } from "@/src/lib/db/schema";
import { eq } from "drizzle-orm";
import { nowIso } from "@/src/lib/db";
import { accounts } from "@/src/lib/db/schema";
import { and, eq, ne } from "drizzle-orm";
export async function POST(request: NextRequest) {
const originCheck = checkSameOrigin(request);
@@ -25,35 +24,37 @@ export async function POST(request: NextRequest) {
}
// Must have a password before unlinking OAuth
if (!user.password_hash) {
if (!user.passwordHash) {
return NextResponse.json(
{ error: "Cannot unlink OAuth: You must set a password first" },
{ status: 400 }
);
}
// Must be using OAuth to unlink
if (user.provider === "credentials") {
// Check if user has any OAuth account links
const oauthAccounts = await db.select().from(accounts).where(
and(
eq(accounts.userId, userId),
ne(accounts.providerId, "credential")
)
);
if (oauthAccounts.length === 0) {
return NextResponse.json(
{ error: "No OAuth account to unlink" },
{ status: 400 }
);
}
const previousProvider = user.provider;
const previousProvider = oauthAccounts[0].providerId;
// Revert to credentials-only
const email = user.email;
const username = email.replace(/@localhost$/, "") || email.split("@")[0];
await db
.update(users)
.set({
provider: "credentials",
subject: `${username}@localhost`,
updatedAt: nowIso()
})
.where(eq(users.id, userId));
// Delete the OAuth account link(s)
await db.delete(accounts).where(
and(
eq(accounts.userId, userId),
ne(accounts.providerId, "credential")
)
);
// Audit log
await createAuditEvent({

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
// Update user avatar
const updatedUser = await updateUserProfile(userId, {
avatar_url: avatarUrl
avatarUrl: avatarUrl
});
if (!updatedUser) {
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
avatarUrl: updatedUser.avatar_url
avatarUrl: updatedUser.avatarUrl
});
} catch (error) {
console.error("Avatar update error:", error);

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { getOAuthProvider, updateOAuthProvider, deleteOAuthProvider } from "@/src/lib/models/oauth-providers";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { createAuditEvent } from "@/src/lib/models/audit";
import { invalidateProviderCache } from "@/src/lib/auth-server";
function redactSecrets(provider: OAuthProvider) {
const clientId = provider.clientId;
return {
...provider,
clientId: clientId.length > 4 ? "••••" + clientId.slice(-4) : "••••",
clientSecret: "••••••••",
};
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
await requireApiAdmin(request);
const { id } = await params;
const provider = await getOAuthProvider(id);
if (!provider) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(redactSecrets(provider));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const body = await request.json();
const existing = await getOAuthProvider(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
// Env-sourced providers can only have `enabled` toggled
if (existing.source === "env") {
const allowedKeys = ["enabled"];
const bodyKeys = Object.keys(body).filter((k) => body[k] !== undefined);
const disallowed = bodyKeys.filter((k) => !allowedKeys.includes(k));
if (disallowed.length > 0) {
return NextResponse.json(
{ error: `Environment-sourced providers can only update: ${allowedKeys.join(", ")}` },
{ status: 400 }
);
}
}
const updated = await updateOAuthProvider(id, body);
if (!updated) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
invalidateProviderCache();
await createAuditEvent({
userId,
action: "update",
entityType: "oauth_provider",
entityId: null,
summary: `Updated OAuth provider "${updated.name}"`,
data: JSON.stringify({ providerId: updated.id, fields: Object.keys(body) }),
});
return NextResponse.json(redactSecrets(updated));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { userId } = await requireApiAdmin(request);
const { id } = await params;
const existing = await getOAuthProvider(id);
if (!existing) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
if (existing.source === "env") {
return NextResponse.json(
{ error: "Cannot delete an environment-sourced OAuth provider" },
{ status: 400 }
);
}
await deleteOAuthProvider(id);
invalidateProviderCache();
await createAuditEvent({
userId,
action: "delete",
entityType: "oauth_provider",
entityId: null,
summary: `Deleted OAuth provider "${existing.name}"`,
data: JSON.stringify({ providerId: existing.id, name: existing.name }),
});
return NextResponse.json({ ok: true });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listOAuthProviders, createOAuthProvider } from "@/src/lib/models/oauth-providers";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { createAuditEvent } from "@/src/lib/models/audit";
import { invalidateProviderCache } from "@/src/lib/auth-server";
function redactSecrets(provider: OAuthProvider) {
const clientId = provider.clientId;
return {
...provider,
clientId: clientId.length > 4 ? "••••" + clientId.slice(-4) : "••••",
clientSecret: "••••••••",
};
}
export async function GET(request: NextRequest) {
try {
await requireApiAdmin(request);
const providers = await listOAuthProviders();
return NextResponse.json(providers.map(redactSecrets));
} catch (error) {
return apiErrorResponse(error);
}
}
export async function POST(request: NextRequest) {
try {
const { userId } = await requireApiAdmin(request);
const body = await request.json();
if (!body.name || typeof body.name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
if (!body.clientId || typeof body.clientId !== "string") {
return NextResponse.json({ error: "clientId is required" }, { status: 400 });
}
if (!body.clientSecret || typeof body.clientSecret !== "string") {
return NextResponse.json({ error: "clientSecret is required" }, { status: 400 });
}
const provider = await createOAuthProvider({
name: body.name,
type: body.type ?? "oidc",
clientId: body.clientId,
clientSecret: body.clientSecret,
issuer: body.issuer ?? null,
authorizationUrl: body.authorizationUrl ?? null,
tokenUrl: body.tokenUrl ?? null,
userinfoUrl: body.userinfoUrl ?? null,
scopes: body.scopes ?? "openid email profile",
autoLink: body.autoLink ?? false,
source: "ui",
});
invalidateProviderCache();
await createAuditEvent({
userId,
action: "create",
entityType: "oauth_provider",
entityId: null,
summary: `Created OAuth provider "${provider.name}"`,
data: JSON.stringify({ providerId: provider.id, name: provider.name, type: provider.type }),
});
return NextResponse.json(redactSecrets(provider), { status: 201 });
} catch (error) {
return apiErrorResponse(error);
}
}

View File

@@ -1982,7 +1982,7 @@ const spec = {
},
User: {
type: "object",
description: "User account (password_hash is never exposed)",
description: "User account (passwordHash is never exposed)",
properties: {
id: { type: "integer" },
email: { type: "string" },
@@ -1990,12 +1990,12 @@ const spec = {
role: { type: "string", enum: ["admin", "user", "viewer"] },
provider: { type: "string", example: "credentials" },
subject: { type: "string" },
avatar_url: { type: ["string", "null"] },
avatarUrl: { type: ["string", "null"] },
status: { type: "string", enum: ["active", "inactive"] },
created_at: { type: "string", format: "date-time" },
updated_at: { type: "string", format: "date-time" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
required: ["id", "email", "role", "provider", "subject", "status", "created_at", "updated_at"],
required: ["id", "email", "role", "provider", "subject", "status", "createdAt", "updatedAt"],
},
AuditLogEvent: {
type: "object",

View File

@@ -3,7 +3,7 @@ import { requireApiUser, requireApiAdmin, apiErrorResponse, ApiAuthError } from
import { getUserById, updateUserProfile, updateUserRole, updateUserStatus, deleteUser } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user;
const { passwordHash: _, ...rest } = user;
void _;
return rest;
}
@@ -62,9 +62,9 @@ export async function PUT(
const profileFields: Record<string, unknown> = {};
if (body.email !== undefined) profileFields.email = body.email;
if (body.name !== undefined) profileFields.name = body.name;
if (body.avatar_url !== undefined) profileFields.avatar_url = body.avatar_url;
if (body.avatarUrl !== undefined) profileFields.avatarUrl = body.avatarUrl;
if (Object.keys(profileFields).length > 0) {
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatar_url?: string | null });
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatarUrl?: string | null });
}
const user = await getUserById(targetId);

View File

@@ -3,7 +3,7 @@ import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
import { listUsers } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user;
const { passwordHash: _, ...rest } = user;
void _;
return rest;
}

View File

@@ -24,6 +24,7 @@
"apexcharts": "^5.10.6",
"autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -35,7 +36,6 @@
"maplibre-gl": "^5.22.0",
"maxmind": "^5.0.6",
"next": "^16.2.3",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"postcss": "^8.5.9",
@@ -77,8 +77,6 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
@@ -135,6 +133,24 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@better-auth/core": ["@better-auth/core@1.6.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-nBftDp+eN1fwXor1O4KQorCXa0tJNDgpab7O1z4NcWUU+3faDpdzqLn5mbXZer2E8ZD4VhjqOfYZ041xnBF5NA=="],
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-KawrNNuhgmpcc5PgLs6HesMckxCscz5J+BQ99iRmU1cLzG/A87IcydrmYtep+K8WHPN0HmZ/i4z/nOBCtxE2qA=="],
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "kysely": "^0.27.0 || ^0.28.0" }, "optionalPeers": ["kysely"] }, "sha512-YMMm75jek/MNCAFWTAaq/U3VPmFnrwZW4NhBjjAwruHQJEIrSZZaOaUEXuUpFRRBhWqg7OOltQcHMwU/45CkuA=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0" } }, "sha512-QvuK5m7NFgkzLPHyab+NORu3J683nj36Tix58qq6DPcniyY6KZk5gY2yyh4+z1wgSjrxwY5NFx/DC2qz8B8NJg=="],
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IvR2Q+1pjzxA4JXI3ED76+6fsqervIpZ2K5MxoX/+miLQhLEmNcbqqcItg4O2kfkxN8h33/ev57sjTW8QH9Tuw=="],
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-bQkXYTo1zPau+xAiMpo1yCjEDSy7i7oeYlkYO+fSfRDCo52DE/9oPOOuI+EStmFkPUNSk9L2rhk8Fulifi8WCg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-o4gHKXqizUxVUUYChZZTowLEzdsz3ViBE/fKFzfHqNFUnF+aVt8QsbLSfipq1WpTIXyJVT/SnH0hgSdWxdssbQ=="],
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
"@clickhouse/client": ["@clickhouse/client@1.18.2", "", { "dependencies": { "@clickhouse/client-common": "1.18.2" } }, "sha512-fuquQswRSHWM6D079ZeuGqkMOsqtcUPL06UdTnowmoeeYjVrqisfVmvnw8pc3OeKS4kVb91oygb/MfLDiMs0TQ=="],
"@clickhouse/client-common": ["@clickhouse/client-common@1.18.2", "", {}, "sha512-J0SG6q9V31ydxonglpj9xhNRsUxCsF71iEZ784yldqMYwsHixj/9xHFDgBDX3DuMiDx/kPDfXnf+pimp08wIBA=="],
@@ -359,11 +375,11 @@
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -377,9 +393,11 @@
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
@@ -663,6 +681,10 @@
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"better-auth": ["better-auth@1.6.2", "", { "dependencies": { "@better-auth/core": "1.6.2", "@better-auth/drizzle-adapter": "1.6.2", "@better-auth/kysely-adapter": "1.6.2", "@better-auth/memory-adapter": "1.6.2", "@better-auth/mongo-adapter": "1.6.2", "@better-auth/prisma-adapter": "1.6.2", "@better-auth/telemetry": "1.6.2", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-5nqDAIj5xexmnk+GjjdrBknJCabi1mlvsVWJbxs4usHreao4vNdxIxINWDzCyDF9iDR1ildRZdXWSiYPAvTHhA=="],
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
@@ -779,6 +801,8 @@
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1063,6 +1087,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1149,6 +1175,8 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
@@ -1157,8 +1185,6 @@
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="],
@@ -1173,8 +1199,6 @@
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@@ -1241,10 +1265,6 @@
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
@@ -1315,6 +1335,8 @@
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
@@ -1335,6 +1357,8 @@
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
"set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
@@ -1549,12 +1573,16 @@
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1563,6 +1591,8 @@
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
@@ -1615,6 +1645,8 @@
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1623,6 +1655,10 @@
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],

View File

@@ -0,0 +1,71 @@
ALTER TABLE `users` ADD COLUMN `email_verified` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `users` ADD COLUMN `username` text;
--> statement-breakpoint
ALTER TABLE `users` ADD COLUMN `display_username` text;
--> statement-breakpoint
DROP TABLE IF EXISTS `sessions`;
--> statement-breakpoint
CREATE TABLE `sessions` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`userId` integer NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`token` text NOT NULL,
`expiresAt` text NOT NULL,
`ipAddress` text,
`userAgent` text,
`createdAt` text NOT NULL,
`updatedAt` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);
--> statement-breakpoint
CREATE INDEX `sessions_user_idx` ON `sessions` (`userId`);
--> statement-breakpoint
CREATE TABLE `accounts` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`userId` integer NOT NULL REFERENCES `users`(`id`) ON DELETE CASCADE,
`accountId` text NOT NULL,
`providerId` text NOT NULL,
`accessToken` text,
`refreshToken` text,
`idToken` text,
`accessTokenExpiresAt` text,
`refreshTokenExpiresAt` text,
`scope` text,
`password` text,
`createdAt` text NOT NULL,
`updatedAt` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `accounts_provider_account_idx` ON `accounts` (`providerId`, `accountId`);
--> statement-breakpoint
CREATE INDEX `accounts_user_idx` ON `accounts` (`userId`);
--> statement-breakpoint
CREATE TABLE `verifications` (
`id` integer PRIMARY KEY AUTOINCREMENT,
`identifier` text NOT NULL,
`value` text NOT NULL,
`expiresAt` text NOT NULL,
`createdAt` text,
`updatedAt` text
);
--> statement-breakpoint
CREATE TABLE `oauth_providers` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL DEFAULT 'oidc',
`client_id` text NOT NULL,
`client_secret` text NOT NULL,
`issuer` text,
`authorization_url` text,
`token_url` text,
`userinfo_url` text,
`scopes` text NOT NULL DEFAULT 'openid email profile',
`auto_link` integer NOT NULL DEFAULT 0,
`enabled` integer NOT NULL DEFAULT 1,
`source` text NOT NULL DEFAULT 'ui',
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_providers_name_unique` ON `oauth_providers` (`name`);

View File

@@ -0,0 +1,178 @@
-- users
ALTER TABLE "users" RENAME COLUMN "password_hash" TO "passwordHash";--> statement-breakpoint
ALTER TABLE "users" RENAME COLUMN "avatar_url" TO "avatarUrl";--> statement-breakpoint
ALTER TABLE "users" RENAME COLUMN "display_username" TO "displayUsername";--> statement-breakpoint
ALTER TABLE "users" RENAME COLUMN "email_verified" TO "emailVerified";--> statement-breakpoint
ALTER TABLE "users" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "users" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- oauth_providers
ALTER TABLE "oauth_providers" RENAME COLUMN "client_id" TO "clientId";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "client_secret" TO "clientSecret";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "authorization_url" TO "authorizationUrl";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "token_url" TO "tokenUrl";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "userinfo_url" TO "userinfoUrl";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "auto_link" TO "autoLink";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "oauth_providers" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- oauth_states
ALTER TABLE "oauth_states" RENAME COLUMN "code_verifier" TO "codeVerifier";--> statement-breakpoint
ALTER TABLE "oauth_states" RENAME COLUMN "redirect_to" TO "redirectTo";--> statement-breakpoint
ALTER TABLE "oauth_states" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "oauth_states" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
-- pending_oauth_links
ALTER TABLE "pending_oauth_links" RENAME COLUMN "user_id" TO "userId";--> statement-breakpoint
ALTER TABLE "pending_oauth_links" RENAME COLUMN "user_email" TO "userEmail";--> statement-breakpoint
ALTER TABLE "pending_oauth_links" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "pending_oauth_links" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
-- settings
ALTER TABLE "settings" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- instances
ALTER TABLE "instances" RENAME COLUMN "base_url" TO "baseUrl";--> statement-breakpoint
ALTER TABLE "instances" RENAME COLUMN "api_token" TO "apiToken";--> statement-breakpoint
ALTER TABLE "instances" RENAME COLUMN "last_sync_at" TO "lastSyncAt";--> statement-breakpoint
ALTER TABLE "instances" RENAME COLUMN "last_sync_error" TO "lastSyncError";--> statement-breakpoint
ALTER TABLE "instances" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "instances" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- access_lists
ALTER TABLE "access_lists" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "access_lists" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "access_lists" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- access_list_entries
ALTER TABLE "access_list_entries" RENAME COLUMN "access_list_id" TO "accessListId";--> statement-breakpoint
ALTER TABLE "access_list_entries" RENAME COLUMN "password_hash" TO "passwordHash";--> statement-breakpoint
ALTER TABLE "access_list_entries" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "access_list_entries" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- certificates
ALTER TABLE "certificates" RENAME COLUMN "domain_names" TO "domainNames";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "auto_renew" TO "autoRenew";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "provider_options" TO "providerOptions";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "certificate_pem" TO "certificatePem";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "private_key_pem" TO "privateKeyPem";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "certificates" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- ca_certificates
ALTER TABLE "ca_certificates" RENAME COLUMN "certificate_pem" TO "certificatePem";--> statement-breakpoint
ALTER TABLE "ca_certificates" RENAME COLUMN "private_key_pem" TO "privateKeyPem";--> statement-breakpoint
ALTER TABLE "ca_certificates" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "ca_certificates" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "ca_certificates" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- issued_client_certificates
ALTER TABLE "issued_client_certificates" RENAME COLUMN "ca_certificate_id" TO "caCertificateId";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "common_name" TO "commonName";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "serial_number" TO "serialNumber";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "fingerprint_sha256" TO "fingerprintSha256";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "certificate_pem" TO "certificatePem";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "valid_from" TO "validFrom";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "valid_to" TO "validTo";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "revoked_at" TO "revokedAt";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "issued_client_certificates" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- proxy_hosts
ALTER TABLE "proxy_hosts" RENAME COLUMN "certificate_id" TO "certificateId";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "access_list_id" TO "accessListId";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "owner_user_id" TO "ownerUserId";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "ssl_forced" TO "sslForced";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "hsts_enabled" TO "hstsEnabled";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "hsts_subdomains" TO "hstsSubdomains";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "allow_websocket" TO "allowWebsocket";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "preserve_host_header" TO "preserveHostHeader";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
ALTER TABLE "proxy_hosts" RENAME COLUMN "skip_https_hostname_validation" TO "skipHttpsHostnameValidation";--> statement-breakpoint
-- api_tokens
ALTER TABLE "api_tokens" RENAME COLUMN "token_hash" TO "tokenHash";--> statement-breakpoint
ALTER TABLE "api_tokens" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "api_tokens" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "api_tokens" RENAME COLUMN "last_used_at" TO "lastUsedAt";--> statement-breakpoint
ALTER TABLE "api_tokens" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
-- audit_events
ALTER TABLE "audit_events" RENAME COLUMN "user_id" TO "userId";--> statement-breakpoint
ALTER TABLE "audit_events" RENAME COLUMN "entity_type" TO "entityType";--> statement-breakpoint
ALTER TABLE "audit_events" RENAME COLUMN "entity_id" TO "entityId";--> statement-breakpoint
ALTER TABLE "audit_events" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- linking_tokens
ALTER TABLE "linking_tokens" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "linking_tokens" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
-- mtls_roles
ALTER TABLE "mtls_roles" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "mtls_roles" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "mtls_roles" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- mtls_certificate_roles
ALTER TABLE "mtls_certificate_roles" RENAME COLUMN "issued_client_certificate_id" TO "issuedClientCertificateId";--> statement-breakpoint
ALTER TABLE "mtls_certificate_roles" RENAME COLUMN "mtls_role_id" TO "mtlsRoleId";--> statement-breakpoint
ALTER TABLE "mtls_certificate_roles" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- mtls_access_rules
ALTER TABLE "mtls_access_rules" RENAME COLUMN "proxy_host_id" TO "proxyHostId";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "path_pattern" TO "pathPattern";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "allowed_role_ids" TO "allowedRoleIds";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "allowed_cert_ids" TO "allowedCertIds";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "deny_all" TO "denyAll";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "mtls_access_rules" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- groups
ALTER TABLE "groups" RENAME COLUMN "created_by" TO "createdBy";--> statement-breakpoint
ALTER TABLE "groups" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "groups" RENAME COLUMN "updated_at" TO "updatedAt";--> statement-breakpoint
-- group_members
ALTER TABLE "group_members" RENAME COLUMN "group_id" TO "groupId";--> statement-breakpoint
ALTER TABLE "group_members" RENAME COLUMN "user_id" TO "userId";--> statement-breakpoint
ALTER TABLE "group_members" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- forward_auth_access
ALTER TABLE "forward_auth_access" RENAME COLUMN "proxy_host_id" TO "proxyHostId";--> statement-breakpoint
ALTER TABLE "forward_auth_access" RENAME COLUMN "user_id" TO "userId";--> statement-breakpoint
ALTER TABLE "forward_auth_access" RENAME COLUMN "group_id" TO "groupId";--> statement-breakpoint
ALTER TABLE "forward_auth_access" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- forward_auth_sessions
ALTER TABLE "forward_auth_sessions" RENAME COLUMN "user_id" TO "userId";--> statement-breakpoint
ALTER TABLE "forward_auth_sessions" RENAME COLUMN "token_hash" TO "tokenHash";--> statement-breakpoint
ALTER TABLE "forward_auth_sessions" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
ALTER TABLE "forward_auth_sessions" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- forward_auth_exchanges
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "session_id" TO "sessionId";--> statement-breakpoint
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "code_hash" TO "codeHash";--> statement-breakpoint
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "session_token" TO "sessionToken";--> statement-breakpoint
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "redirect_uri" TO "redirectUri";--> statement-breakpoint
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
ALTER TABLE "forward_auth_exchanges" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- forward_auth_redirect_intents
ALTER TABLE "forward_auth_redirect_intents" RENAME COLUMN "rid_hash" TO "ridHash";--> statement-breakpoint
ALTER TABLE "forward_auth_redirect_intents" RENAME COLUMN "redirect_uri" TO "redirectUri";--> statement-breakpoint
ALTER TABLE "forward_auth_redirect_intents" RENAME COLUMN "expires_at" TO "expiresAt";--> statement-breakpoint
ALTER TABLE "forward_auth_redirect_intents" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
-- l4_proxy_hosts
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "listen_address" TO "listenAddress";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "matcher_type" TO "matcherType";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "matcher_value" TO "matcherValue";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "tls_termination" TO "tlsTermination";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "proxy_protocol_version" TO "proxyProtocolVersion";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "proxy_protocol_receive" TO "proxyProtocolReceive";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "owner_user_id" TO "ownerUserId";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "created_at" TO "createdAt";--> statement-breakpoint
ALTER TABLE "l4_proxy_hosts" RENAME COLUMN "updated_at" TO "updatedAt";

View File

@@ -0,0 +1,27 @@
-- Recreate users table for Better Auth compatibility:
-- 1. provider/subject: nullable with defaults (Better Auth doesn't set these)
-- 2. emailVerified: INTEGER (matches Better Auth boolean→int conversion)
CREATE TABLE users_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL,
name TEXT,
passwordHash TEXT,
role TEXT NOT NULL DEFAULT 'user',
provider TEXT DEFAULT '',
subject TEXT DEFAULT '',
avatarUrl TEXT,
status TEXT NOT NULL DEFAULT 'active',
username TEXT,
displayUsername TEXT,
emailVerified INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
--> statement-breakpoint
INSERT INTO users_new SELECT id, email, name, passwordHash, role, provider, subject, avatarUrl, status, username, displayUsername, emailVerified, createdAt, updatedAt FROM users;
--> statement-breakpoint
DROP TABLE users;
--> statement-breakpoint
ALTER TABLE users_new RENAME TO users;
--> statement-breakpoint
CREATE UNIQUE INDEX users_email_unique ON users (email);

View File

@@ -141,6 +141,27 @@
"when": 1775700000000,
"tag": "0019_drop_analytics_tables",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1776000000000,
"tag": "0020_better_auth",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1776100000000,
"tag": "0021_camelcase_columns",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1776200000000,
"tag": "0022_nullable_provider_subject",
"breakpoints": true
}
]
}

12804
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@
"apexcharts": "^5.10.6",
"autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -50,7 +51,6 @@
"maplibre-gl": "^5.22.0",
"maxmind": "^5.0.6",
"next": "^16.2.3",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6",
"node-forge": "^1.4.0",
"postcss": "^8.5.9",

View File

@@ -1,6 +1,7 @@
import { auth } from "@/src/lib/auth";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import crypto from "node:crypto";
import { auth } from "@/src/lib/auth";
/**
* Next.js Proxy for route protection.
@@ -34,8 +35,7 @@ function buildCsp(nonce: string): string {
return directives.join("; ");
}
export default auth((req) => {
const isAuthenticated = !!req.auth;
export default async function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
// Allow public routes
@@ -51,6 +51,10 @@ export default auth((req) => {
return NextResponse.next();
}
// Check authentication for protected routes
const session = await auth(req);
const isAuthenticated = !!session?.user;
// Redirect unauthenticated users to login
if (!isAuthenticated && !pathname.startsWith("/login")) {
const loginUrl = new URL("/login", req.url);
@@ -77,7 +81,7 @@ export default auth((req) => {
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
return response;
});
}
export const config = {
matcher: [

View File

@@ -234,7 +234,7 @@ export function ManageIssuedClientCertsDialog({
const result = await revokeIssuedClientCertificateAction(id);
setItems((current) =>
current.map((item) =>
item.id === id ? { ...item, revoked_at: result.revokedAt, updated_at: result.revokedAt } : item
item.id === id ? { ...item, revokedAt: result.revokedAt, updatedAt: result.revokedAt } : item
)
);
router.refresh();
@@ -244,8 +244,8 @@ export function ManageIssuedClientCertsDialog({
});
}
const visibleItems = showRevoked ? items : items.filter((i) => !i.revoked_at);
const revokedCount = items.filter((i) => i.revoked_at).length;
const visibleItems = showRevoked ? items : items.filter((i) => !i.revokedAt);
const revokedCount = items.filter((i) => i.revokedAt).length;
return (
<AppDialog
@@ -285,31 +285,31 @@ export function ManageIssuedClientCertsDialog({
</p>
) : (
visibleItems.map((item) => {
const expired = new Date(item.valid_to).getTime() < Date.now();
const expired = new Date(item.validTo).getTime() < Date.now();
return (
<div key={item.id} className="rounded-lg border p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-semibold text-base">{item.common_name}</p>
<p className="text-sm text-muted-foreground">Serial {item.serial_number}</p>
<p className="font-semibold text-base">{item.commonName}</p>
<p className="text-sm text-muted-foreground">Serial {item.serialNumber}</p>
</div>
<div className="flex flex-wrap gap-1 justify-end">
<Badge variant={item.revoked_at ? "secondary" : "default"}>
{item.revoked_at ? "Revoked" : "Active"}
<Badge variant={item.revokedAt ? "secondary" : "default"}>
{item.revokedAt ? "Revoked" : "Active"}
</Badge>
<Badge variant={expired ? "destructive" : "outline"}>
{expired
? `Expired ${formatDateTime(item.valid_to)}`
: `Expires ${formatDateTime(item.valid_to)}`}
? `Expired ${formatDateTime(item.validTo)}`
: `Expires ${formatDateTime(item.validTo)}`}
</Badge>
</div>
</div>
<p className="text-sm text-muted-foreground">Issued {formatDateTime(item.created_at)}</p>
<p className="text-sm text-muted-foreground">Issued {formatDateTime(item.createdAt)}</p>
<p className="text-sm text-muted-foreground font-mono break-all">
SHA-256 {formatFingerprint(item.fingerprint_sha256)}
SHA-256 {formatFingerprint(item.fingerprintSha256)}
</p>
{item.revoked_at ? (
<p className="text-sm text-muted-foreground">Revoked {formatDateTime(item.revoked_at)}</p>
{item.revokedAt ? (
<p className="text-sm text-muted-foreground">Revoked {formatDateTime(item.revokedAt)}</p>
) : (
<div className="flex justify-end">
<Button

View File

@@ -68,20 +68,20 @@ function L4HostForm({
const [enabled, setEnabled] = useState(initialData?.enabled ?? true);
const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp");
const [matcherType, setMatcherType] = useState(
initialData?.matcher_type ?? "none"
initialData?.matcherType ?? "none"
);
const defaultLbAccordion = initialData?.load_balancer?.enabled
const defaultLbAccordion = initialData?.loadBalancer?.enabled
? "load-balancer"
: undefined;
const defaultDnsAccordion = initialData?.dns_resolver?.enabled
const defaultDnsAccordion = initialData?.dnsResolver?.enabled
? "dns-resolver"
: undefined;
const defaultGeoblockAccordion = initialData?.geoblock?.enabled
? "geoblock"
: undefined;
const defaultUpstreamDnsAccordion =
initialData?.upstream_dns_resolution?.enabled === true
initialData?.upstreamDnsResolution?.enabled === true
? "upstream-dns"
: undefined;
@@ -163,7 +163,7 @@ function L4HostForm({
id="listen_address"
name="listen_address"
placeholder=":5432"
defaultValue={initialData?.listen_address ?? ""}
defaultValue={initialData?.listenAddress ?? ""}
required
/>
</FormField>
@@ -220,7 +220,7 @@ function L4HostForm({
id="matcher_value"
name="matcher_value"
placeholder="db.example.com, api.example.com"
defaultValue={initialData?.matcher_value?.join(", ") ?? ""}
defaultValue={initialData?.matcherValue?.join(", ") ?? ""}
required
/>
</FormField>
@@ -231,7 +231,7 @@ function L4HostForm({
<Switch
id="tls_termination"
name="tls_termination"
defaultChecked={initialData?.tls_termination ?? false}
defaultChecked={initialData?.tlsTermination ?? false}
/>
<Label htmlFor="tls_termination">TLS Termination</Label>
</div>
@@ -241,7 +241,7 @@ function L4HostForm({
<Switch
id="proxy_protocol_receive"
name="proxy_protocol_receive"
defaultChecked={initialData?.proxy_protocol_receive ?? false}
defaultChecked={initialData?.proxyProtocolReceive ?? false}
/>
<Label htmlFor="proxy_protocol_receive">
Accept inbound PROXY protocol
@@ -254,7 +254,7 @@ function L4HostForm({
</Label>
<Select
name="proxy_protocol_version"
defaultValue={initialData?.proxy_protocol_version ?? "__none__"}
defaultValue={initialData?.proxyProtocolVersion ?? "__none__"}
>
<SelectTrigger id="proxy_protocol_version">
<SelectValue placeholder="None" />
@@ -292,7 +292,7 @@ function L4HostForm({
id="lb_enabled"
name="lb_enabled"
defaultChecked={
initialData?.load_balancer?.enabled ?? false
initialData?.loadBalancer?.enabled ?? false
}
/>
<Label htmlFor="lb_enabled">Enable Load Balancing</Label>
@@ -302,7 +302,7 @@ function L4HostForm({
<Select
name="lb_policy"
defaultValue={
initialData?.load_balancer?.policy ?? "random"
initialData?.loadBalancer?.policy ?? "random"
}
>
<SelectTrigger id="lb_policy">
@@ -325,7 +325,7 @@ function L4HostForm({
name="lb_try_duration"
placeholder="5s"
defaultValue={
initialData?.load_balancer?.tryDuration ?? ""
initialData?.loadBalancer?.tryDuration ?? ""
}
/>
</FormField>
@@ -335,7 +335,7 @@ function L4HostForm({
name="lb_try_interval"
placeholder="250ms"
defaultValue={
initialData?.load_balancer?.tryInterval ?? ""
initialData?.loadBalancer?.tryInterval ?? ""
}
/>
</FormField>
@@ -344,7 +344,7 @@ function L4HostForm({
id="lb_retries"
name="lb_retries"
type="number"
defaultValue={initialData?.load_balancer?.retries ?? ""}
defaultValue={initialData?.loadBalancer?.retries ?? ""}
/>
</FormField>
@@ -361,7 +361,7 @@ function L4HostForm({
id="lb_active_health_enabled"
name="lb_active_health_enabled"
defaultChecked={
initialData?.load_balancer?.activeHealthCheck?.enabled ??
initialData?.loadBalancer?.activeHealthCheck?.enabled ??
false
}
/>
@@ -378,7 +378,7 @@ function L4HostForm({
name="lb_active_health_port"
type="number"
defaultValue={
initialData?.load_balancer?.activeHealthCheck?.port ?? ""
initialData?.loadBalancer?.activeHealthCheck?.port ?? ""
}
/>
</FormField>
@@ -388,7 +388,7 @@ function L4HostForm({
name="lb_active_health_interval"
placeholder="30s"
defaultValue={
initialData?.load_balancer?.activeHealthCheck?.interval ??
initialData?.loadBalancer?.activeHealthCheck?.interval ??
""
}
/>
@@ -399,7 +399,7 @@ function L4HostForm({
name="lb_active_health_timeout"
placeholder="5s"
defaultValue={
initialData?.load_balancer?.activeHealthCheck?.timeout ?? ""
initialData?.loadBalancer?.activeHealthCheck?.timeout ?? ""
}
/>
</FormField>
@@ -417,7 +417,7 @@ function L4HostForm({
id="lb_passive_health_enabled"
name="lb_passive_health_enabled"
defaultChecked={
initialData?.load_balancer?.passiveHealthCheck?.enabled ??
initialData?.loadBalancer?.passiveHealthCheck?.enabled ??
false
}
/>
@@ -434,7 +434,7 @@ function L4HostForm({
name="lb_passive_health_fail_duration"
placeholder="30s"
defaultValue={
initialData?.load_balancer?.passiveHealthCheck
initialData?.loadBalancer?.passiveHealthCheck
?.failDuration ?? ""
}
/>
@@ -445,7 +445,7 @@ function L4HostForm({
name="lb_passive_health_max_fails"
type="number"
defaultValue={
initialData?.load_balancer?.passiveHealthCheck?.maxFails ??
initialData?.loadBalancer?.passiveHealthCheck?.maxFails ??
""
}
/>
@@ -459,7 +459,7 @@ function L4HostForm({
name="lb_passive_health_unhealthy_latency"
placeholder="5s"
defaultValue={
initialData?.load_balancer?.passiveHealthCheck
initialData?.loadBalancer?.passiveHealthCheck
?.unhealthyLatency ?? ""
}
/>
@@ -493,7 +493,7 @@ function L4HostForm({
<Switch
id="dns_enabled"
name="dns_enabled"
defaultChecked={initialData?.dns_resolver?.enabled ?? false}
defaultChecked={initialData?.dnsResolver?.enabled ?? false}
/>
<Label htmlFor="dns_enabled">Enable Custom DNS</Label>
</div>
@@ -507,7 +507,7 @@ function L4HostForm({
name="dns_resolvers"
placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={
initialData?.dns_resolver?.resolvers?.join("\n") ?? ""
initialData?.dnsResolver?.resolvers?.join("\n") ?? ""
}
rows={2}
/>
@@ -522,7 +522,7 @@ function L4HostForm({
name="dns_fallbacks"
placeholder="8.8.4.4"
defaultValue={
initialData?.dns_resolver?.fallbacks?.join("\n") ?? ""
initialData?.dnsResolver?.fallbacks?.join("\n") ?? ""
}
rows={1}
/>
@@ -532,7 +532,7 @@ function L4HostForm({
id="dns_timeout"
name="dns_timeout"
placeholder="5s"
defaultValue={initialData?.dns_resolver?.timeout ?? ""}
defaultValue={initialData?.dnsResolver?.timeout ?? ""}
/>
</FormField>
</div>
@@ -571,7 +571,7 @@ function L4HostForm({
<Label htmlFor="geoblock_mode">Mode</Label>
<Select
name="geoblock_mode"
defaultValue={initialData?.geoblock_mode ?? "merge"}
defaultValue={initialData?.geoblockMode ?? "merge"}
>
<SelectTrigger id="geoblock_mode">
<SelectValue />
@@ -752,9 +752,9 @@ function L4HostForm({
<Select
name="upstream_dns_resolution_mode"
defaultValue={
initialData?.upstream_dns_resolution?.enabled === true
initialData?.upstreamDnsResolution?.enabled === true
? "enabled"
: initialData?.upstream_dns_resolution?.enabled === false
: initialData?.upstreamDnsResolution?.enabled === false
? "disabled"
: "inherit"
}
@@ -778,7 +778,7 @@ function L4HostForm({
<Select
name="upstream_dns_resolution_family"
defaultValue={
initialData?.upstream_dns_resolution?.family ?? "inherit"
initialData?.upstreamDnsResolution?.family ?? "inherit"
}
>
<SelectTrigger id="upstream_dns_resolution_family">
@@ -948,7 +948,7 @@ export function DeleteL4HostDialog({
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground w-20 shrink-0">Listen</span>
<span className="font-mono text-xs">{host.listen_address}</span>
<span className="font-mono text-xs">{host.listenAddress}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-muted-foreground w-20 shrink-0">Upstreams</span>

View File

@@ -29,7 +29,7 @@ type Props = {
export function MtlsRolesTab({ roles, issuedCerts, search }: Props) {
const [createOpen, setCreateOpen] = useState(false);
const activeCerts = issuedCerts.filter(c => !c.revoked_at);
const activeCerts = issuedCerts.filter(c => !c.revokedAt);
const filtered = roles.filter(r =>
!search ||
@@ -128,7 +128,7 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
const loadAssignments = useCallback(() => {
fetch(`/api/v1/mtls-roles/${role.id}`)
.then(r => r.ok ? r.json() : { certificate_ids: [] })
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificate_ids)); setLoaded(true); })
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificateIds)); setLoaded(true); })
.catch(() => setLoaded(true));
}, [role.id]);
@@ -143,7 +143,7 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
setAssignedIds(prev => { const next = new Set(prev); next.delete(certId); return next; });
} else {
await fetch(`/api/v1/mtls-roles/${role.id}/certificates`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificate_id: certId }),
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificateId: certId }),
});
setAssignedIds(prev => new Set(prev).add(certId));
}
@@ -234,9 +234,9 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
<div className="flex items-center gap-2.5">
<Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} />
<div>
<p className="text-sm font-medium leading-tight">{cert.common_name}</p>
<p className="text-sm font-medium leading-tight">{cert.commonName}</p>
<p className="text-xs text-muted-foreground">
expires {new Date(cert.valid_to).toLocaleDateString()}
expires {new Date(cert.validTo).toLocaleDateString()}
</p>
</div>
</div>

View File

@@ -32,7 +32,7 @@ export function CpmForwardAuthFields({
groups = [],
currentAccess,
}: {
cpmForwardAuth?: ProxyHost["cpm_forward_auth"] | null;
cpmForwardAuth?: ProxyHost["cpmForwardAuth"] | null;
users?: UserEntry[];
groups?: GroupEntry[];
currentAccess?: ForwardAuthAccessData | null;

View File

@@ -9,7 +9,7 @@ import { ProxyHost } from "@/lib/models/proxy-hosts";
export function DnsResolverFields({
dnsResolver
}: {
dnsResolver?: ProxyHost["dns_resolver"] | null;
dnsResolver?: ProxyHost["dnsResolver"] | null;
}) {
const initial = dnsResolver ?? null;
const [enabled, setEnabled] = useState(initial?.enabled ?? false);

View File

@@ -87,8 +87,8 @@ export function CreateHostDialog({
</Alert>
)}
<SettingsToggles
hstsSubdomains={initialData?.hsts_subdomains}
skipHttpsValidation={initialData?.skip_https_hostname_validation}
hstsSubdomains={initialData?.hstsSubdomains}
skipHttpsValidation={initialData?.skipHttpsHostnameValidation}
enabled={true}
/>
<div>
@@ -118,7 +118,7 @@ export function CreateHostDialog({
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "__none__")}>
<Select name="certificate_id" defaultValue={String(initialData?.certificateId ?? "__none__")}>
<SelectTrigger aria-label="Certificate">
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
@@ -134,7 +134,7 @@ export function CreateHostDialog({
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "__none__")}>
<Select name="access_list_id" defaultValue={String(initialData?.accessListId ?? "__none__")}>
<SelectTrigger aria-label="Access List">
<SelectValue placeholder="None" />
</SelectTrigger>
@@ -149,14 +149,14 @@ export function CreateHostDialog({
</Select>
</div>
<RedirectsFields initialData={initialData?.redirects} />
<LocationRulesFields initialData={initialData?.location_rules} />
<LocationRulesFields initialData={initialData?.locationRules} />
<RewriteFields initialData={initialData?.rewrite} />
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
placeholder='[{"handler": "headers", ...}]'
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
defaultValue={initialData?.customPreHandlersJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
@@ -166,7 +166,7 @@ export function CreateHostDialog({
<Textarea
name="custom_reverse_proxy_json"
placeholder='{"headers": {"request": {...}}}'
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
defaultValue={initialData?.customReverseProxyJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -175,13 +175,13 @@ export function CreateHostDialog({
</div>
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={initialData?.cpm_forward_auth}
cpmForwardAuth={initialData?.cpmForwardAuth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
/>
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
<LoadBalancerFields loadBalancer={initialData?.loadBalancer} />
<DnsResolverFields dnsResolver={initialData?.dnsResolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstreamDnsResolution} />
<GeoBlockFields />
<WafFields value={initialData?.waf} />
<MtlsFields
@@ -246,8 +246,8 @@ export function EditHostDialog({
</Alert>
)}
<SettingsToggles
hstsSubdomains={host.hsts_subdomains}
skipHttpsValidation={host.skip_https_hostname_validation}
hstsSubdomains={host.hstsSubdomains}
skipHttpsValidation={host.skipHttpsHostnameValidation}
enabled={host.enabled}
/>
<div>
@@ -269,7 +269,7 @@ export function EditHostDialog({
<UpstreamInput defaultUpstreams={host.upstreams} />
<div>
<label className="text-sm font-medium mb-1 block">Certificate</label>
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "__none__")}>
<Select name="certificate_id" defaultValue={String(host.certificateId ?? "__none__")}>
<SelectTrigger aria-label="Certificate">
<SelectValue placeholder="Managed by Caddy (Auto)" />
</SelectTrigger>
@@ -285,7 +285,7 @@ export function EditHostDialog({
</div>
<div>
<label className="text-sm font-medium mb-1 block">Access List</label>
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "__none__")}>
<Select name="access_list_id" defaultValue={String(host.accessListId ?? "__none__")}>
<SelectTrigger aria-label="Access List">
<SelectValue placeholder="None" />
</SelectTrigger>
@@ -300,13 +300,13 @@ export function EditHostDialog({
</Select>
</div>
<RedirectsFields initialData={host.redirects} />
<LocationRulesFields initialData={host.location_rules} />
<LocationRulesFields initialData={host.locationRules} />
<RewriteFields initialData={host.rewrite} />
<div>
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
<Textarea
name="custom_pre_handlers_json"
defaultValue={host.custom_pre_handlers_json ?? ""}
defaultValue={host.customPreHandlersJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
@@ -315,7 +315,7 @@ export function EditHostDialog({
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
<Textarea
name="custom_reverse_proxy_json"
defaultValue={host.custom_reverse_proxy_json ?? ""}
defaultValue={host.customReverseProxyJson ?? ""}
rows={3}
/>
<p className="text-xs text-muted-foreground mt-1">
@@ -324,18 +324,18 @@ export function EditHostDialog({
</div>
<AuthentikFields authentik={host.authentik} />
<CpmForwardAuthFields
cpmForwardAuth={host.cpm_forward_auth}
cpmForwardAuth={host.cpmForwardAuth}
users={forwardAuthUsers}
groups={forwardAuthGroups}
currentAccess={forwardAuthAccess}
/>
<LoadBalancerFields loadBalancer={host.load_balancer} />
<DnsResolverFields dnsResolver={host.dns_resolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
<LoadBalancerFields loadBalancer={host.loadBalancer} />
<DnsResolverFields dnsResolver={host.dnsResolver} />
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstreamDnsResolution} />
<GeoBlockFields
initialValues={{
geoblock: host.geoblock,
geoblock_mode: host.geoblock_mode,
geoblock_mode: host.geoblockMode,
}}
/>
<WafFields value={host.waf} />

View File

@@ -19,7 +19,7 @@ const LOAD_BALANCING_POLICIES = [
export function LoadBalancerFields({
loadBalancer
}: {
loadBalancer?: ProxyHost["load_balancer"] | null;
loadBalancer?: ProxyHost["loadBalancer"] | null;
}) {
const initial = loadBalancer ?? null;
const [enabled, setEnabled] = useState(initial?.enabled ?? false);

View File

@@ -37,13 +37,13 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
const [editRule, setEditRule] = useState<MtlsAccessRule | null>(null);
const isEditMode = !!proxyHostId;
const activeCerts = issuedClientCerts.filter(c => !c.revoked_at);
const activeCerts = issuedClientCerts.filter(c => !c.revokedAt);
const certsByCA = new Map<number, IssuedClientCertificate[]>();
for (const cert of activeCerts) {
const list = certsByCA.get(cert.ca_certificate_id) ?? [];
const list = certsByCA.get(cert.caCertificateId) ?? [];
list.push(cert);
certsByCA.set(cert.ca_certificate_id, list);
certsByCA.set(cert.caCertificateId, list);
}
const loadRules = useCallback(() => {
@@ -143,7 +143,7 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
<span className="text-sm font-medium">{role.name}</span>
{role.description && <span className="text-xs text-muted-foreground ml-2"> {role.description}</span>}
</label>
<Badge variant="outline" className="text-xs shrink-0">{role.certificate_count} certs</Badge>
<Badge variant="outline" className="text-xs shrink-0">{role.certificateCount} certs</Badge>
</div>
))}
</div>
@@ -201,10 +201,10 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
className="ml-4"
/>
<label className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleCert(cert.id)}>
<span className="text-sm">{cert.common_name}</span>
<span className="text-sm">{cert.commonName}</span>
</label>
<span className="text-xs text-muted-foreground shrink-0">
expires {new Date(cert.valid_to).toLocaleDateString()}
expires {new Date(cert.validTo).toLocaleDateString()}
</span>
</div>
))}
@@ -249,20 +249,20 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
<div className="flex flex-col gap-1.5">
{rules.map(rule => (
<div key={rule.id} className="group flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.path_pattern}</code>
{rule.deny_all ? (
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.pathPattern}</code>
{rule.denyAll ? (
<Badge variant="destructive" className="text-xs gap-1"><Ban className="h-3 w-3" /> Deny</Badge>
) : (
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
{rule.allowed_role_ids.map(roleId => {
{rule.allowedRoleIds.map(roleId => {
const role = mtlsRoles.find(r => r.id === roleId);
return <Badge key={`r-${roleId}`} variant="secondary" className="text-xs">{role?.name ?? `#${roleId}`}</Badge>;
})}
{rule.allowed_cert_ids.map(certId => {
{rule.allowedCertIds.map(certId => {
const cert = issuedClientCerts.find(c => c.id === certId);
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.common_name ?? `#${certId}`}</Badge>;
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.commonName ?? `#${certId}`}</Badge>;
})}
{rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && (
{rule.allowedRoleIds.length === 0 && rule.allowedCertIds.length === 0 && (
<span className="text-xs text-destructive italic">No roles/certs effectively denied</span>
)}
</div>
@@ -297,12 +297,12 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
onClose: () => void; proxyHostId: number; roles: MtlsRole[]; activeCerts: IssuedClientCertificate[];
title: string; submitLabel: string; existing?: MtlsAccessRule; onSaved: () => void;
}) {
const [pathPattern, setPathPattern] = useState(existing?.path_pattern ?? "*");
const [pathPattern, setPathPattern] = useState(existing?.pathPattern ?? "*");
const [priority, setPriority] = useState(String(existing?.priority ?? 0));
const [description, setDescription] = useState(existing?.description ?? "");
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowed_role_ids ?? []);
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowed_cert_ids ?? []);
const [denyAll, setDenyAll] = useState(existing?.deny_all ?? false);
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowedRoleIds ?? []);
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowedCertIds ?? []);
const [denyAll, setDenyAll] = useState(existing?.denyAll ?? false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState("");
@@ -373,7 +373,7 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
{activeCerts.map(cert => (
<div key={cert.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
<Checkbox checked={selectedCertIds.includes(cert.id)} onCheckedChange={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} />
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.common_name}</label>
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.commonName}</label>
</div>
))}
</div>

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
import { useState } from "react";
type ToggleSetting = {
name: "hsts_subdomains" | "skip_https_hostname_validation";
name: "hstsSubdomains" | "skipHttpsHostnameValidation";
label: string;
description: string;
defaultChecked: boolean;
@@ -21,8 +21,8 @@ export function SettingsToggles({
enabled = true
}: SettingsTogglesProps) {
const [values, setValues] = useState({
hsts_subdomains: hstsSubdomains,
skip_https_hostname_validation: skipHttpsValidation,
hstsSubdomains: hstsSubdomains,
skipHttpsHostnameValidation: skipHttpsValidation,
enabled: enabled
});
@@ -32,16 +32,16 @@ export function SettingsToggles({
const settings: ToggleSetting[] = [
{
name: "hsts_subdomains",
name: "hstsSubdomains",
label: "HSTS Subdomains",
description: "Include subdomains in the Strict-Transport-Security header",
defaultChecked: values.hsts_subdomains,
defaultChecked: values.hstsSubdomains,
},
{
name: "skip_https_hostname_validation",
name: "skipHttpsHostnameValidation",
label: "Skip HTTPS Validation",
description: "Skip SSL certificate hostname verification for backend connections",
defaultChecked: values.skip_https_hostname_validation,
defaultChecked: values.skipHttpsHostnameValidation,
}
];

View File

@@ -25,7 +25,7 @@ function toFamilyMode(family: "ipv6" | "ipv4" | "both" | null | undefined): Fami
export function UpstreamDnsResolutionFields({
upstreamDnsResolution
}: {
upstreamDnsResolution?: ProxyHost["upstream_dns_resolution"] | null;
upstreamDnsResolution?: ProxyHost["upstreamDnsResolution"] | null;
}) {
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
const family = toFamilyMode(upstreamDnsResolution?.family);

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 {
export type Session = {
user: {
id: string;
email: string;
name: string | null;
role: string;
provider?: string;
} & DefaultSession["user"];
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;
}
}
// 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
status?: string;
avatarUrl?: string | null;
subject?: string;
};
}
});
}
const userId = typeof baUser.id === "string" ? Number(baUser.id) : baUser.id;
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);
}
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;
}
},
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);
// Always fetch current role/status from database to reflect changes immediately
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;
if (!currentUser || currentUser.status !== "active") {
return null;
}
}
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();
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,
},
};
}
/**
* 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,
// Insert OAuth account link
await db.insert(accounts).values({
userId,
accountId: providerAccountId,
providerId: provider,
createdAt: nowIso(),
updatedAt: nowIso()
})
.where(eq(users.id, userId));
});
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
// 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({
provider,
subject: providerAccountId,
avatarUrl: avatarUrl ?? user.avatar_url,
updatedAt: nowIso()
})
.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
// 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({
provider,
subject: providerAccountId,
avatarUrl: avatarUrl ?? user.avatar_url,
updatedAt: nowIso()
})
.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
}

View File

@@ -18,7 +18,7 @@ staticClients:
secret: cpm-test-secret
name: "CPM E2E Test"
redirectURIs:
- "http://localhost:3000/api/auth/callback/oauth2"
- "http://localhost:3000/api/auth/oauth2/callback/dex"
enablePasswordDB: true

View File

@@ -17,6 +17,8 @@ services:
OAUTH_TOKEN_URL: http://dex:5556/dex/token
OAUTH_USERINFO_URL: http://dex:5556/dex/userinfo
OAUTH_ALLOW_AUTO_LINKING: "true"
# Disable Better Auth rate limiting for E2E tests (many rapid auth requests)
AUTH_RATE_LIMIT_ENABLED: "false"
clickhouse:
environment:
CLICKHOUSE_PASSWORD: "test-clickhouse-password-2026"

View File

@@ -54,17 +54,30 @@ async function apiGet(page: Page, path: string) {
return page.request.get(`${API}${path}`);
}
/** Log into Dex with email/password. Handles the Dex login form. */
/** Log into Dex with email/password. Handles the Dex login form.
* If Dex has an existing session and auto-redirects, this is a no-op. */
async function dexLogin(page: Page, email: string, password: string) {
// Wait for either Dex login form OR auto-redirect back to our app.
// Dex may auto-redirect if it has an active session from a prior login.
try {
await page.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
} catch {
// Already redirected back — no Dex login needed (Dex has existing session)
return;
}
// Dex shows a "Log in to dex" page with a link to the local (password) connector
// or goes straight to the login form
const loginLink = page.getByRole('link', { name: /log in with email/i });
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await loginLink.click();
}
// If Dex auto-redirected during the wait above, skip the form
if (!page.url().includes('localhost:5556')) return;
// Wait for the Dex login form to appear
await expect(page.getByRole('button', { name: /login/i })).toBeVisible({ timeout: 10_000 });
// Dex uses "email address" and "Password" as accessible names
await page.getByRole('textbox', { name: /email/i }).fill(email);
await page.getByRole('textbox', { name: /password/i }).fill(password);
await page.getByRole('button', { name: /login/i }).click();
@@ -75,6 +88,46 @@ async function freshContext(page: Page): Promise<BrowserContext> {
return page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
}
/**
* Perform an OAuth login through the /login page and verify the user was created.
* Uses a fresh browser context to avoid session conflicts between users.
* Retries once on failure (Better Auth OAuth state can race between rapid logins).
*/
async function doOAuthLogin(page: Page, user: { email: string; password: string }) {
for (let attempt = 0; attempt < 2; attempt++) {
const ctx = await freshContext(page);
const p = await ctx.newPage();
try {
await p.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle' });
console.log(`[doOAuthLogin] ${user.email} on: ${p.url()}`);
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
await oauthButton.click();
// Wait for navigation to Dex
await p.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
console.log(`[doOAuthLogin] ${user.email} after nav: ${p.url()}`);
await dexLogin(p, user.email, user.password);
// Wait for redirect back to the app
await p.waitForURL((url) => {
try {
const u = new URL(url);
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
} catch { return false; }
}, { timeout: 30_000 });
// Verify the URL doesn't indicate an error
const finalUrl = p.url();
if (finalUrl.includes('error=') || finalUrl.includes('/login')) {
if (attempt === 0) continue; // retry
throw new Error(`OAuth login failed for ${user.email}: ${finalUrl}`);
}
return; // success
} finally {
await ctx.close();
}
}
}
/**
* Perform OAuth login on the portal and return the callback URL.
* Does NOT navigate to the callback (test domains aren't DNS-resolvable).
@@ -149,8 +202,8 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
name: 'OAuth Forward Auth Test',
domains: [DOMAIN],
upstreams: ['echo-server:8080'],
ssl_forced: false,
cpm_forward_auth: { enabled: true },
sslForced: false,
cpmForwardAuth: { enabled: true },
});
expect(res.status()).toBe(201);
const host = await res.json();
@@ -159,43 +212,11 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
});
test('setup: trigger OAuth login for alice to create her user account', async ({ page }) => {
const ctx = await freshContext(page);
const p = await ctx.newPage();
try {
await p.goto(`${BASE_URL}/login`);
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
await oauthButton.click();
await dexLogin(p, ALICE.email, ALICE.password);
await p.waitForURL((url) => {
try {
const u = new URL(url);
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
} catch { return false; }
}, { timeout: 30_000 });
} finally {
await ctx.close();
}
await doOAuthLogin(page, ALICE);
});
test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => {
const ctx = await freshContext(page);
const p = await ctx.newPage();
try {
await p.goto(`${BASE_URL}/login`);
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
await oauthButton.click();
await dexLogin(p, BOB.email, BOB.password);
await p.waitForURL((url) => {
try {
const u = new URL(url);
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
} catch { return false; }
}, { timeout: 30_000 });
} finally {
await ctx.close();
}
await doOAuthLogin(page, BOB);
});
test('setup: find alice and bob user IDs', async ({ page }) => {

View File

@@ -28,8 +28,8 @@ test.describe.serial('Forward Auth', () => {
name: 'Functional Forward Auth Test',
domains: [DOMAIN],
upstreams: ['echo-server:8080'],
ssl_forced: false,
cpm_forward_auth: { enabled: true },
sslForced: false,
cpmForwardAuth: { enabled: true },
},
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
});

View File

@@ -61,7 +61,7 @@ test.describe('L4 Proxy Hosts page', () => {
await expect(sortBtn).toBeVisible();
await sortBtn.click();
await expect(page).toHaveURL(/sortBy=listen_address/);
await expect(page).toHaveURL(/sortBy=listenAddress/);
});
test('creates a new L4 proxy host', async ({ page }) => {

View File

@@ -58,13 +58,25 @@ function ensureTestUser(username: string, password: string, role: string) {
const now = new Date().toISOString();
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
if (existing) {
db.run("UPDATE users SET password_hash = ?, role = ?, status = 'active', updated_at = ? WHERE email = ?",
db.run("UPDATE users SET passwordHash = ?, role = ?, status = 'active', updatedAt = ? WHERE email = ?",
[hash, "${role}", now, email]);
// Update or create credential account for Better Auth
const acc = db.query("SELECT id FROM accounts WHERE userId = ? AND providerId = 'credential'").get(existing.id);
if (acc) {
db.run("UPDATE accounts SET password = ?, updatedAt = ? WHERE id = ?", [hash, now, acc.id]);
} else {
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
[existing.id, String(existing.id), hash, now, now]);
}
} else {
db.run(
"INSERT INTO users (email, name, password_hash, role, provider, subject, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'credentials', ?, 'active', ?, ?)",
[email, "${username}", hash, "${role}", "${username}", now, now]
"INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
[email, "${username}", hash, "${role}", "${username}", "${username}", now, now]
);
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
// Create credential account for Better Auth
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
[user.id, String(user.id), hash, now, now]);
}
`;
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
@@ -90,7 +102,7 @@ async function loginAs(
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
// The login client does router.replace('/') on success — wait for that
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 });
await page.close();
return context;
}
@@ -281,8 +293,12 @@ test.describe('Role-based access control', () => {
// ── Admin user — can access all pages ───────────────────────────────
test('admin role: all dashboard pages are accessible', async ({ browser }) => {
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
test('admin role: all dashboard pages are accessible', async ({ browser }, testInfo) => {
testInfo.setTimeout(90_000);
// Use the pre-authenticated admin state from global-setup
const adminContext = await browser.newContext({
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
});
try {
for (const path of ALL_DASHBOARD_PAGES) {
const page = await adminContext.newPage();
@@ -298,7 +314,9 @@ test.describe('Role-based access control', () => {
});
test('admin role: sidebar shows all nav items', async ({ browser }) => {
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
const adminContext = await browser.newContext({
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
});
try {
const page = await adminContext.newPage();
await page.goto('/');

View File

@@ -58,65 +58,65 @@ describe('mtls-access-rules CRUD', () => {
it('createMtlsAccessRule creates a rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/admin/*',
allowed_role_ids: [1, 2],
allowed_cert_ids: [10],
proxyHostId: host.id,
pathPattern: '/admin/*',
allowedRoleIds: [1, 2],
allowedCertIds: [10],
priority: 5,
description: 'admin only',
}, userId);
expect(rule.proxy_host_id).toBe(host.id);
expect(rule.path_pattern).toBe('/admin/*');
expect(rule.allowed_role_ids).toEqual([1, 2]);
expect(rule.allowed_cert_ids).toEqual([10]);
expect(rule.proxyHostId).toBe(host.id);
expect(rule.pathPattern).toBe('/admin/*');
expect(rule.allowedRoleIds).toEqual([1, 2]);
expect(rule.allowedCertIds).toEqual([10]);
expect(rule.priority).toBe(5);
expect(rule.description).toBe('admin only');
expect(rule.deny_all).toBe(false);
expect(rule.denyAll).toBe(false);
});
it('createMtlsAccessRule trims path_pattern', async () => {
it('createMtlsAccessRule trims pathPattern', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: ' /api/* ',
proxyHostId: host.id,
pathPattern: ' /api/* ',
}, userId);
expect(rule.path_pattern).toBe('/api/*');
expect(rule.pathPattern).toBe('/api/*');
});
it('createMtlsAccessRule defaults arrays to empty', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '*',
proxyHostId: host.id,
pathPattern: '*',
}, userId);
expect(rule.allowed_role_ids).toEqual([]);
expect(rule.allowed_cert_ids).toEqual([]);
expect(rule.deny_all).toBe(false);
expect(rule.allowedRoleIds).toEqual([]);
expect(rule.allowedCertIds).toEqual([]);
expect(rule.denyAll).toBe(false);
expect(rule.priority).toBe(0);
});
it('createMtlsAccessRule with deny_all', async () => {
it('createMtlsAccessRule with denyAll', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id,
path_pattern: '/blocked/*',
deny_all: true,
proxyHostId: host.id,
pathPattern: '/blocked/*',
denyAll: true,
}, userId);
expect(rule.deny_all).toBe(true);
expect(rule.denyAll).toBe(true);
});
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
const host = await insertHost();
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/c', priority: 1 }, userId);
const rules = await listMtlsAccessRules(host.id);
expect(rules).toHaveLength(3);
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c
expect(rules[0].pathPattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].pathPattern).toBe('/b'); // priority 1, path /b
expect(rules[2].pathPattern).toBe('/c'); // priority 1, path /c
});
it('listMtlsAccessRules returns empty array for host with no rules', async () => {
@@ -128,24 +128,24 @@ describe('mtls-access-rules CRUD', () => {
it('listMtlsAccessRules only returns rules for the specified host', async () => {
const host1 = await insertHost('h1');
const host2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId);
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId);
await createMtlsAccessRule({ proxyHostId: host1.id, pathPattern: '/h1' }, userId);
await createMtlsAccessRule({ proxyHostId: host2.id, pathPattern: '/h2' }, userId);
const rules = await listMtlsAccessRules(host1.id);
expect(rules).toHaveLength(1);
expect(rules[0].path_pattern).toBe('/h1');
expect(rules[0].pathPattern).toBe('/h1');
});
it('getMtlsAccessRule returns a single rule', async () => {
const host = await insertHost();
const created = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
proxyHostId: host.id, pathPattern: '/test',
}, userId);
const fetched = await getMtlsAccessRule(created.id);
expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id);
expect(fetched!.path_pattern).toBe('/test');
expect(fetched!.pathPattern).toBe('/test');
});
it('getMtlsAccessRule returns null for non-existent rule', async () => {
@@ -155,34 +155,34 @@ describe('mtls-access-rules CRUD', () => {
it('updateMtlsAccessRule updates fields', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/old', priority: 0,
proxyHostId: host.id, pathPattern: '/old', priority: 0,
}, userId);
const updated = await updateMtlsAccessRule(rule.id, {
path_pattern: '/new',
pathPattern: '/new',
priority: 99,
allowed_role_ids: [5],
deny_all: true,
allowedRoleIds: [5],
denyAll: true,
description: 'updated',
}, userId);
expect(updated.path_pattern).toBe('/new');
expect(updated.pathPattern).toBe('/new');
expect(updated.priority).toBe(99);
expect(updated.allowed_role_ids).toEqual([5]);
expect(updated.deny_all).toBe(true);
expect(updated.allowedRoleIds).toEqual([5]);
expect(updated.denyAll).toBe(true);
expect(updated.description).toBe('updated');
});
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
allowed_role_ids: [1], priority: 5, description: 'original',
proxyHostId: host.id, pathPattern: '/test',
allowedRoleIds: [1], priority: 5, description: 'original',
}, userId);
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
expect(updated.path_pattern).toBe('/test');
expect(updated.allowed_role_ids).toEqual([1]);
expect(updated.pathPattern).toBe('/test');
expect(updated.allowedRoleIds).toEqual([1]);
expect(updated.description).toBe('original');
expect(updated.priority).toBe(10);
});
@@ -194,7 +194,7 @@ describe('mtls-access-rules CRUD', () => {
it('deleteMtlsAccessRule removes the rule', async () => {
const host = await insertHost();
const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test',
proxyHostId: host.id, pathPattern: '/test',
}, userId);
await deleteMtlsAccessRule(rule.id, 1);
@@ -221,9 +221,9 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('groups rules by proxy host ID', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/b' }, userId);
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/c' }, userId);
const map = await getAccessRulesForHosts([h1.id, h2.id]);
expect(map.get(h1.id)).toHaveLength(2);
@@ -233,8 +233,8 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('excludes hosts not in the query list', async () => {
const h1 = await insertHost('h1');
const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId);
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/b' }, userId);
const map = await getAccessRulesForHosts([h1.id]);
expect(map.has(h1.id)).toBe(true);
@@ -243,20 +243,20 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('rules within a host are ordered by priority desc, path asc', async () => {
const h = await insertHost();
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/m', priority: 10 }, userId);
const map = await getAccessRulesForHosts([h.id]);
const rules = map.get(h.id)!;
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z
expect(rules[2].path_pattern).toBe('/a'); // priority 1
expect(rules[0].pathPattern).toBe('/m'); // priority 10, path /m
expect(rules[1].pathPattern).toBe('/z'); // priority 10, path /z
expect(rules[2].pathPattern).toBe('/a'); // priority 1
});
});
describe('JSON parsing edge cases in access rules', () => {
it('handles malformed allowed_role_ids JSON gracefully', async () => {
it('handles malformed allowedRoleIds JSON gracefully', async () => {
const host = await insertHost();
const now = nowIso();
// Insert directly with bad JSON
@@ -267,7 +267,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
expect(rules[0].allowedRoleIds).toEqual([]);
});
it('filters non-numeric values from JSON arrays', async () => {
@@ -280,7 +280,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([1, 3]);
expect(rules[0].allowedRoleIds).toEqual([1, 3]);
});
it('handles non-array JSON', async () => {
@@ -293,7 +293,7 @@ describe('JSON parsing edge cases in access rules', () => {
});
const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]);
expect(rules[0].allowed_cert_ids).toEqual([]);
expect(rules[0].allowedRoleIds).toEqual([]);
expect(rules[0].allowedCertIds).toEqual([]);
});
});

View File

@@ -91,7 +91,7 @@ describe('mtls-roles model CRUD', () => {
const role = await createMtlsRole({ name: 'admin', description: 'Admin role' }, userId);
expect(role.name).toBe('admin');
expect(role.description).toBe('Admin role');
expect(role.certificate_count).toBe(0);
expect(role.certificateCount).toBe(0);
expect(role.id).toBeGreaterThan(0);
});
@@ -115,7 +115,7 @@ describe('mtls-roles model CRUD', () => {
await assignRoleToCertificate(role.id, cert1.id, 1);
const roles = await listMtlsRoles();
expect(roles[0].certificate_count).toBe(1);
expect(roles[0].certificateCount).toBe(1);
});
it('listMtlsRoles returns empty array when no roles', async () => {
@@ -123,7 +123,7 @@ describe('mtls-roles model CRUD', () => {
expect(roles).toEqual([]);
});
it('getMtlsRole returns role with certificate_ids', async () => {
it('getMtlsRole returns role with certificateIds', async () => {
const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1);
@@ -131,9 +131,9 @@ describe('mtls-roles model CRUD', () => {
const fetched = await getMtlsRole(role.id);
expect(fetched).not.toBeNull();
expect(fetched!.certificate_ids).toHaveLength(2);
expect(fetched!.certificate_ids).toContain(cert1.id);
expect(fetched!.certificate_ids).toContain(cert2.id);
expect(fetched!.certificateIds).toHaveLength(2);
expect(fetched!.certificateIds).toContain(cert1.id);
expect(fetched!.certificateIds).toContain(cert2.id);
});
it('getMtlsRole returns null for non-existent role', async () => {
@@ -177,7 +177,7 @@ describe('mtls-roles certificate assignments', () => {
await assignRoleToCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toContain(cert1.id);
expect(fetched!.certificateIds).toContain(cert1.id);
});
it('assignRoleToCertificate throws for non-existent role', async () => {
@@ -204,7 +204,7 @@ describe('mtls-roles certificate assignments', () => {
await removeRoleFromCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toEqual([]);
expect(fetched!.certificateIds).toEqual([]);
});
it('removeRoleFromCertificate throws for non-existent role', async () => {
@@ -247,7 +247,7 @@ describe('mtls-roles certificate assignments', () => {
await assignRoleToCertificate(role.id, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1);
const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toHaveLength(2);
expect(fetched!.certificateIds).toHaveLength(2);
});
});

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeEach } from "vitest";
import { createTestDb, type TestDb } from "../helpers/db";
import { oauthProviders } from "@/src/lib/db/schema";
import { randomUUID } from "node:crypto";
import { encryptSecret, decryptSecret } from "@/src/lib/secret";
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
/**
* Simulates what syncEnvOAuthProviders does:
* - If no env-sourced provider with this name exists, create one
* - If env-sourced provider exists, update it
* - If UI-sourced provider with same name exists, skip
*/
async function syncProvider(envConfig: {
name: string;
clientId: string;
clientSecret: string;
issuer?: string | null;
autoLink?: boolean;
}) {
const now = nowIso();
const existing = await db.query.oauthProviders.findFirst({
where: (table, { eq }) => eq(table.name, envConfig.name),
});
if (existing && existing.source === "env") {
// Update existing env-sourced provider
const { eq } = await import("drizzle-orm");
await db.update(oauthProviders).set({
clientId: encryptSecret(envConfig.clientId),
clientSecret: encryptSecret(envConfig.clientSecret),
issuer: envConfig.issuer ?? null,
autoLink: envConfig.autoLink ?? false,
updatedAt: now,
}).where(eq(oauthProviders.id, existing.id));
} else if (!existing) {
// Create new env-sourced provider
await db.insert(oauthProviders).values({
id: randomUUID(),
name: envConfig.name,
type: "oidc",
clientId: encryptSecret(envConfig.clientId),
clientSecret: encryptSecret(envConfig.clientSecret),
issuer: envConfig.issuer ?? null,
authorizationUrl: null,
tokenUrl: null,
userinfoUrl: null,
scopes: "openid email profile",
autoLink: envConfig.autoLink ?? false,
enabled: true,
source: "env",
createdAt: now,
updatedAt: now,
});
}
// If a UI-sourced provider with the same name exists, skip
}
describe("syncEnvOAuthProviders", () => {
it("creates env-sourced provider when configured", async () => {
await syncProvider({
name: "TestIdP",
clientId: "env-client-id",
clientSecret: "env-client-secret",
issuer: "https://idp.example.com",
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
expect(providers[0].name).toBe("TestIdP");
expect(providers[0].source).toBe("env");
expect(decryptSecret(providers[0].clientId)).toBe("env-client-id");
expect(providers[0].issuer).toBe("https://idp.example.com");
});
it("updates existing env-sourced provider when config changes", async () => {
// First sync
await syncProvider({
name: "MyIdP",
clientId: "old-id",
clientSecret: "old-secret",
issuer: "https://old.example.com",
autoLink: false,
});
// Second sync with changed config
await syncProvider({
name: "MyIdP",
clientId: "new-id",
clientSecret: "new-secret",
issuer: "https://new.example.com",
autoLink: true,
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
expect(decryptSecret(providers[0].clientId)).toBe("new-id");
expect(providers[0].issuer).toBe("https://new.example.com");
expect(providers[0].autoLink).toBe(true);
});
it("does not overwrite a UI-sourced provider with the same name", async () => {
const now = nowIso();
// Create a UI-sourced provider first
await db.insert(oauthProviders).values({
id: randomUUID(),
name: "SharedName",
type: "oidc",
clientId: encryptSecret("ui-id"),
clientSecret: encryptSecret("ui-secret"),
scopes: "openid email profile",
autoLink: false,
enabled: true,
source: "ui",
createdAt: now,
updatedAt: now,
});
// Try to sync env with the same name
await syncProvider({
name: "SharedName",
clientId: "env-id",
clientSecret: "env-secret",
});
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(1);
// Should still be the UI provider, not overwritten
expect(providers[0].source).toBe("ui");
expect(decryptSecret(providers[0].clientId)).toBe("ui-id");
});
it("skips when OAuth is not configured (no providers created)", async () => {
// Simply don't call syncProvider - verify empty
const providers = await db.query.oauthProviders.findMany();
expect(providers).toHaveLength(0);
});
});

View File

@@ -0,0 +1,205 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createTestDb, type TestDb } from '../helpers/db';
import { oauthProviders } from '@/src/lib/db/schema';
import { eq } from 'drizzle-orm';
import { randomUUID } from 'node:crypto';
import { encryptSecret, decryptSecret } from '@/src/lib/secret';
let db: TestDb;
beforeEach(() => {
db = createTestDb();
});
function nowIso() {
return new Date().toISOString();
}
async function insertProvider(overrides: Partial<typeof oauthProviders.$inferInsert> = {}) {
const now = nowIso();
const [provider] = await db.insert(oauthProviders).values({
id: randomUUID(),
name: 'Test OIDC',
type: 'oidc',
clientId: encryptSecret('test-client-id'),
clientSecret: encryptSecret('test-client-secret'),
issuer: 'https://issuer.example.com',
scopes: 'openid email profile',
autoLink: false,
enabled: true,
source: 'ui',
createdAt: now,
updatedAt: now,
...overrides,
}).returning();
return provider;
}
describe('oauth-providers integration', () => {
it('creates and lists providers', async () => {
await insertProvider({ name: 'GitHub' });
await insertProvider({ name: 'Google', id: randomUUID() });
const rows = await db.query.oauthProviders.findMany({
orderBy: (t, { asc }) => asc(t.name),
});
expect(rows).toHaveLength(2);
expect(rows[0].name).toBe('GitHub');
expect(rows[1].name).toBe('Google');
// enabled defaults to true
expect(rows[0].enabled).toBe(true);
expect(rows[1].enabled).toBe(true);
});
it('encrypts client secret on create and decrypts correctly', async () => {
const plainSecret = 'super-secret-value-12345';
const provider = await insertProvider({
clientSecret: encryptSecret(plainSecret),
});
// The stored value should be encrypted (starts with enc:v1:)
expect(provider.clientSecret).not.toBe(plainSecret);
expect(provider.clientSecret.startsWith('enc:v1:')).toBe(true);
// Decrypting should yield the original value
const decrypted = decryptSecret(provider.clientSecret);
expect(decrypted).toBe(plainSecret);
});
it('encrypts client ID on create and decrypts correctly', async () => {
const plainClientId = 'my-client-id-abc';
const provider = await insertProvider({
clientId: encryptSecret(plainClientId),
});
expect(provider.clientId).not.toBe(plainClientId);
expect(provider.clientId.startsWith('enc:v1:')).toBe(true);
expect(decryptSecret(provider.clientId)).toBe(plainClientId);
});
it('updates a provider name and enabled flag', async () => {
const provider = await insertProvider({ name: 'Old Name', enabled: true });
const now = nowIso();
const [updated] = await db
.update(oauthProviders)
.set({ name: 'New Name', enabled: false, updatedAt: now })
.where(eq(oauthProviders.id, provider.id))
.returning();
expect(updated.name).toBe('New Name');
expect(updated.enabled).toBe(false);
expect(updated.updatedAt).toBe(now);
});
it('deletes a UI-sourced provider successfully', async () => {
const provider = await insertProvider({ source: 'ui' });
await db.delete(oauthProviders).where(eq(oauthProviders.id, provider.id));
const row = await db.query.oauthProviders.findFirst({
where: (t, { eq }) => eq(t.id, provider.id),
});
expect(row).toBeUndefined();
});
it('env-sourced provider can be identified by source field', async () => {
const provider = await insertProvider({ source: 'env' });
const row = await db.query.oauthProviders.findFirst({
where: (t, { eq }) => eq(t.id, provider.id),
});
expect(row).toBeDefined();
expect(row!.source).toBe('env');
});
it('getProviderDisplayList returns only enabled providers', async () => {
await insertProvider({ name: 'Enabled Provider', enabled: true, id: randomUUID() });
await insertProvider({ name: 'Disabled Provider', enabled: false, id: randomUUID() });
const enabledRows = await db.query.oauthProviders.findMany({
where: (t, { eq }) => eq(t.enabled, true),
orderBy: (t, { asc }) => asc(t.name),
columns: { id: true, name: true },
});
expect(enabledRows).toHaveLength(1);
expect(enabledRows[0].name).toBe('Enabled Provider');
});
it('listEnabledOAuthProviders filters correctly', async () => {
await insertProvider({ name: 'Active', enabled: true, id: randomUUID() });
await insertProvider({ name: 'Inactive', enabled: false, id: randomUUID() });
await insertProvider({ name: 'Also Active', enabled: true, id: randomUUID() });
const enabled = await db.query.oauthProviders.findMany({
where: (t, { eq }) => eq(t.enabled, true),
orderBy: (t, { asc }) => asc(t.name),
});
expect(enabled).toHaveLength(2);
expect(enabled.map((r) => r.name)).toEqual(['Active', 'Also Active']);
});
it('unique name constraint prevents duplicate names', async () => {
await insertProvider({ name: 'UniqueProvider', id: randomUUID() });
await expect(
insertProvider({ name: 'UniqueProvider', id: randomUUID() })
).rejects.toThrow();
});
it('re-encrypts secret on update', async () => {
const provider = await insertProvider({
clientSecret: encryptSecret('original-secret'),
});
const newEncrypted = encryptSecret('updated-secret');
const [updated] = await db
.update(oauthProviders)
.set({ clientSecret: newEncrypted, updatedAt: nowIso() })
.where(eq(oauthProviders.id, provider.id))
.returning();
expect(updated.clientSecret).not.toBe(provider.clientSecret);
expect(decryptSecret(updated.clientSecret)).toBe('updated-secret');
});
it('stores all optional URL fields', async () => {
const provider = await insertProvider({
issuer: 'https://issuer.example.com',
authorizationUrl: 'https://auth.example.com/authorize',
tokenUrl: 'https://auth.example.com/token',
userinfoUrl: 'https://auth.example.com/userinfo',
});
const row = await db.query.oauthProviders.findFirst({
where: (t, { eq }) => eq(t.id, provider.id),
});
expect(row!.issuer).toBe('https://issuer.example.com');
expect(row!.authorizationUrl).toBe('https://auth.example.com/authorize');
expect(row!.tokenUrl).toBe('https://auth.example.com/token');
expect(row!.userinfoUrl).toBe('https://auth.example.com/userinfo');
});
it('default type is oidc and default source is ui', async () => {
const now = nowIso();
const [provider] = await db.insert(oauthProviders).values({
id: randomUUID(),
name: 'Defaults Test',
clientId: encryptSecret('cid'),
clientSecret: encryptSecret('csecret'),
scopes: 'openid',
createdAt: now,
updatedAt: now,
}).returning();
expect(provider.type).toBe('oidc');
expect(provider.source).toBe('ui');
expect(provider.autoLink).toBe(false);
expect(provider.enabled).toBe(true);
});
});

View File

@@ -40,7 +40,7 @@ beforeEach(() => {
describe('authenticateApiRequest', () => {
it('authenticates via Bearer token', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 42, role: 'admin' },
});
@@ -91,7 +91,7 @@ describe('authenticateApiRequest', () => {
describe('requireApiAdmin', () => {
it('allows admin users', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 1, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 1, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 1, role: 'admin' },
});
@@ -101,7 +101,7 @@ describe('requireApiAdmin', () => {
it('rejects non-admin users with 403', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 2, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 2, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 2, role: 'user' },
});
@@ -154,7 +154,7 @@ describe('requireApiUser', () => {
it('CSRF check skips for Bearer-authenticated POST', async () => {
mockValidateToken.mockResolvedValue({
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
user: { id: 42, role: 'admin' },
});

View File

@@ -53,8 +53,8 @@ const sampleUser = {
name: 'Admin User',
email: 'admin@example.com',
role: 'admin',
password_hash: '$2b$10$hashedpassword',
created_at: '2026-01-01',
passwordHash: '$2b$10$hashedpassword',
createdAt: '2026-01-01',
};
beforeEach(() => {
@@ -64,7 +64,7 @@ beforeEach(() => {
});
describe('GET /api/v1/users', () => {
it('returns list of users with password_hash stripped', async () => {
it('returns list of users with passwordHash stripped', async () => {
mockListUsers.mockResolvedValue([sampleUser] as any);
const response = await listGET(createMockRequest());
@@ -72,7 +72,7 @@ describe('GET /api/v1/users', () => {
expect(response.status).toBe(200);
expect(data).toHaveLength(1);
expect(data[0]).not.toHaveProperty('password_hash');
expect(data[0]).not.toHaveProperty('passwordHash');
expect(data[0].name).toBe('Admin User');
expect(data[0].email).toBe('admin@example.com');
});
@@ -87,14 +87,14 @@ describe('GET /api/v1/users', () => {
});
describe('GET /api/v1/users/[id]', () => {
it('returns a user by id with password_hash stripped', async () => {
it('returns a user by id with passwordHash stripped', async () => {
mockGetUserById.mockResolvedValue(sampleUser as any);
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
expect(data.name).toBe('Admin User');
});
@@ -128,7 +128,7 @@ describe('GET /api/v1/users/[id]', () => {
expect(response.status).toBe(200);
expect(data.id).toBe(5);
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
});
});
@@ -144,7 +144,7 @@ describe('PUT /api/v1/users/[id]', () => {
expect(response.status).toBe(200);
expect(data.name).toBe('Updated Name');
expect(data).not.toHaveProperty('password_hash');
expect(data).not.toHaveProperty('passwordHash');
expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, { name: 'Updated Name' });
});

View File

@@ -20,10 +20,10 @@ import {
function makeRule(overrides: Partial<MtlsAccessRuleLike> = {}): MtlsAccessRuleLike {
return {
path_pattern: "/admin/*",
allowed_role_ids: [],
allowed_cert_ids: [],
deny_all: false,
pathPattern: "/admin/*",
allowedRoleIds: [],
allowedCertIds: [],
denyAll: false,
...overrides,
};
}
@@ -54,7 +54,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [1, 2] });
const rule = makeRule({ allowedRoleIds: [1, 2] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"]));
@@ -67,7 +67,7 @@ describe("resolveAllowedFingerprints", () => {
[20, "fp_y"],
]);
const rule = makeRule({ allowed_cert_ids: [10, 20] });
const rule = makeRule({ allowedCertIds: [10, 20] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_x", "fp_y"]));
@@ -79,7 +79,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>([[10, "fp_b"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result).toEqual(new Set(["fp_a", "fp_b"]));
@@ -91,7 +91,7 @@ describe("resolveAllowedFingerprints", () => {
]);
const certFpMap = new Map<number, string>([[10, "fp_a"]]);
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(1);
@@ -102,7 +102,7 @@ describe("resolveAllowedFingerprints", () => {
const roleFpMap = new Map<number, Set<string>>();
const certFpMap = new Map<number, string>();
const rule = makeRule({ allowed_role_ids: [999], allowed_cert_ids: [999] });
const rule = makeRule({ allowedRoleIds: [999], allowedCertIds: [999] });
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
expect(result.size).toBe(0);
@@ -146,7 +146,7 @@ describe("buildMtlsRbacSubroutes", () => {
const roleFpMap = new Map<number, Set<string>>([
[1, new Set(["fp_admin"])],
]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -174,8 +174,8 @@ describe("buildMtlsRbacSubroutes", () => {
expect(catchAll.terminal).toBe(true);
});
it("generates 403 for deny_all rule", () => {
const rules = [makeRule({ deny_all: true })];
it("generates 403 for denyAll rule", () => {
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
@@ -188,7 +188,7 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("generates 403 when rule has no matching fingerprints", () => {
const rules = [makeRule({ allowed_role_ids: [999] })]; // role doesn't exist
const rules = [makeRule({ allowedRoleIds: [999] })]; // role doesn't exist
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
expect(result).not.toBeNull();
@@ -206,8 +206,8 @@ describe("buildMtlsRbacSubroutes", () => {
[2, new Set(["fp_api"])],
]);
const rules = [
makeRule({ path_pattern: "/admin/*", allowed_role_ids: [1] }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1, 2] }),
makeRule({ pathPattern: "/admin/*", allowedRoleIds: [1] }),
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1, 2] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -219,7 +219,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("uses direct cert fingerprints as overrides", () => {
const certFpMap = new Map<number, string>([[42, "fp_special"]]);
const rules = [makeRule({ allowed_cert_ids: [42] })];
const rules = [makeRule({ allowedCertIds: [42] })];
const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy);
@@ -230,7 +230,7 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("catch-all route includes base handlers + reverse proxy", () => {
const rules = [makeRule({ deny_all: true })];
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const catchAll = result![result!.length - 1] as Record<string, unknown>;
@@ -242,7 +242,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("allow route includes base handlers + reverse proxy", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
const allowRoute = result![0] as Record<string, unknown>;
@@ -252,17 +252,17 @@ describe("buildMtlsRbacSubroutes", () => {
});
it("deny route body is 'mTLS access denied'", () => {
const rules = [makeRule({ deny_all: true })];
const rules = [makeRule({ denyAll: true })];
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
const denyHandler = (result![0] as any).handle[0];
expect(denyHandler.body).toBe("mTLS access denied");
});
it("handles mixed deny_all and role-based rules", () => {
it("handles mixed denyAll and role-based rules", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [
makeRule({ path_pattern: "/secret/*", deny_all: true }),
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1] }),
makeRule({ pathPattern: "/secret/*", denyAll: true }),
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1] }),
];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
@@ -281,7 +281,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("handles rule with both roles and certs combined", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp_role"])]]);
const certFpMap = new Map<number, string>([[42, "fp_cert"]]);
const rules = [makeRule({ allowed_role_ids: [1], allowed_cert_ids: [42] })];
const rules = [makeRule({ allowedRoleIds: [1], allowedCertIds: [42] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy);
const match = (result![0] as any).match[0];
@@ -292,7 +292,7 @@ describe("buildMtlsRbacSubroutes", () => {
it("preserves base handlers order in generated routes", () => {
const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }];
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
const rules = [makeRule({ allowed_role_ids: [1] })];
const rules = [makeRule({ allowedRoleIds: [1] })];
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy);
const allowHandlers = (result![0] as any).handle;
@@ -344,14 +344,14 @@ describe("buildFingerprintCelExpression edge cases", () => {
describe("resolveAllowedFingerprints edge cases", () => {
it("handles empty arrays in rule", () => {
const rule = makeRule({ allowed_role_ids: [], allowed_cert_ids: [] });
const rule = makeRule({ allowedRoleIds: [], allowedCertIds: [] });
const result = resolveAllowedFingerprints(rule, new Map(), new Map());
expect(result.size).toBe(0);
});
it("handles role with empty fingerprint set", () => {
const roleFpMap = new Map<number, Set<string>>([[1, new Set()]]);
const rule = makeRule({ allowed_role_ids: [1] });
const rule = makeRule({ allowedRoleIds: [1] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result.size).toBe(0);
});
@@ -362,7 +362,7 @@ describe("resolveAllowedFingerprints edge cases", () => {
[2, new Set(["b", "c"])],
[3, new Set(["c", "d"])],
]);
const rule = makeRule({ allowed_role_ids: [1, 2, 3] });
const rule = makeRule({ allowedRoleIds: [1, 2, 3] });
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
expect(result).toEqual(new Set(["a", "b", "c", "d"]));
});

View File

@@ -68,7 +68,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: '',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
@@ -78,7 +78,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'sctp' as any,
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
@@ -88,7 +88,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '',
listenAddress: '',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
@@ -98,7 +98,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: '10.0.0.1',
listenAddress: '10.0.0.1',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
@@ -108,7 +108,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':0',
listenAddress: ':0',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
@@ -118,7 +118,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':99999',
listenAddress: ':99999',
upstreams: ['10.0.0.1:5432'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
@@ -128,7 +128,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
@@ -138,7 +138,7 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1'],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
@@ -148,9 +148,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'udp',
listen_address: ':5353',
listenAddress: ':5353',
upstreams: ['8.8.8.8:53'],
tls_termination: true,
tlsTermination: true,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
});
@@ -159,10 +159,10 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'tls_sni',
matcher_value: [],
matcherType: 'tls_sni',
matcherValue: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
@@ -171,10 +171,10 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':8080',
listenAddress: ':8080',
upstreams: ['10.0.0.1:8080'],
matcher_type: 'http_host',
matcher_value: [],
matcherType: 'http_host',
matcherValue: [],
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
});
@@ -183,9 +183,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
proxy_protocol_version: 'v3' as any,
proxyProtocolVersion: 'v3' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
});
@@ -194,9 +194,9 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Test',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'invalid' as any,
matcherType: 'invalid' as any,
};
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
});
@@ -205,33 +205,33 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Full Featured',
protocol: 'tcp',
listen_address: ':993',
listenAddress: ':993',
upstreams: ['localhost:143'],
matcher_type: 'tls_sni',
matcher_value: ['mail.example.com'],
tls_termination: true,
proxy_protocol_version: 'v1',
proxy_protocol_receive: true,
matcherType: 'tls_sni',
matcherValue: ['mail.example.com'],
tlsTermination: true,
proxyProtocolVersion: 'v1',
proxyProtocolReceive: true,
enabled: true,
};
const result = await createL4ProxyHost(input, 1);
expect(result).toBeDefined();
expect(result.name).toBe('Full Featured');
expect(result.protocol).toBe('tcp');
expect(result.listen_address).toBe(':993');
expect(result.listenAddress).toBe(':993');
expect(result.upstreams).toEqual(['localhost:143']);
expect(result.matcher_type).toBe('tls_sni');
expect(result.matcher_value).toEqual(['mail.example.com']);
expect(result.tls_termination).toBe(true);
expect(result.proxy_protocol_version).toBe('v1');
expect(result.proxy_protocol_receive).toBe(true);
expect(result.matcherType).toBe('tls_sni');
expect(result.matcherValue).toEqual(['mail.example.com']);
expect(result.tlsTermination).toBe(true);
expect(result.proxyProtocolVersion).toBe('v1');
expect(result.proxyProtocolReceive).toBe(true);
});
it('accepts valid UDP proxy', async () => {
const input: L4ProxyHostInput = {
name: 'DNS',
protocol: 'udp',
listen_address: ':5353',
listenAddress: ':5353',
upstreams: ['8.8.8.8:53'],
};
const result = await createL4ProxyHost(input, 1);
@@ -243,54 +243,54 @@ describe('L4 proxy host create validation', () => {
const input: L4ProxyHostInput = {
name: 'Bound',
protocol: 'tcp',
listen_address: '0.0.0.0:5432',
listenAddress: '0.0.0.0:5432',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.listen_address).toBe('0.0.0.0:5432');
expect(result.listenAddress).toBe('0.0.0.0:5432');
});
it('accepts none matcher without matcher_value', async () => {
it('accepts none matcher without matcherValue', async () => {
const input: L4ProxyHostInput = {
name: 'Catch All',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432'],
matcher_type: 'none',
matcherType: 'none',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('none');
expect(result.matcherType).toBe('none');
});
it('accepts proxy_protocol matcher without matcher_value', async () => {
it('accepts proxy_protocol matcher without matcherValue', async () => {
const input: L4ProxyHostInput = {
name: 'PP Detect',
protocol: 'tcp',
listen_address: ':8443',
listenAddress: ':8443',
upstreams: ['10.0.0.1:443'],
matcher_type: 'proxy_protocol',
matcherType: 'proxy_protocol',
};
const result = await createL4ProxyHost(input, 1);
expect(result.matcher_type).toBe('proxy_protocol');
expect(result.matcherType).toBe('proxy_protocol');
});
it('trims whitespace from name and listen_address', async () => {
it('trims whitespace from name and listenAddress', async () => {
const input: L4ProxyHostInput = {
name: ' Spacey Name ',
protocol: 'tcp',
listen_address: ' :5432 ',
listenAddress: ' :5432 ',
upstreams: ['10.0.0.1:5432'],
};
const result = await createL4ProxyHost(input, 1);
expect(result.name).toBe('Spacey Name');
expect(result.listen_address).toBe(':5432');
expect(result.listenAddress).toBe(':5432');
});
it('deduplicates upstreams', async () => {
const input: L4ProxyHostInput = {
name: 'Dedup',
protocol: 'tcp',
listen_address: ':5432',
listenAddress: ':5432',
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
};
const result = await createL4ProxyHost(input, 1);