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 node_modules
package-lock.json
.next .next
out out
dist dist

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { Shield } from "lucide-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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; 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. // 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. // The rid is an opaque server-side ID — the actual redirect URI is never in the URL.
const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`; const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`;
signIn(providerId, { callbackUrl }); authClient.signIn.social({ provider: providerId, callbackURL: callbackUrl });
}; };
const disabled = pending || !!oauthPending; const disabled = pending || !!oauthPending;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -254,7 +254,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
id: "domains", id: "domains",
label: "Domains", label: "Domains",
render: (c: ManagedCertView) => ( 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; id: number;
name: string; name: string;
domains: string[]; domains: string[];
ssl_forced: boolean; sslForced: boolean;
enabled: boolean; enabled: boolean;
}; };
@@ -37,7 +37,7 @@ export type ImportedCertView = {
usedBy: { id: number; name: string; domains: string[] }[]; 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; const PER_PAGE = 25;
@@ -124,7 +124,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
id: r.id, id: r.id,
name: r.name, name: r.name,
domains: JSON.parse(r.domains) as string[], domains: JSON.parse(r.domains) as string[],
ssl_forced: r.sslForced, sslForced: r.sslForced,
enabled: r.enabled, enabled: r.enabled,
})); }));
@@ -143,9 +143,9 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
const importedCerts: ImportedCertView[] = []; const importedCerts: ImportedCertView[] = [];
const managedCerts: ManagedCertView[] = []; const managedCerts: ManagedCertView[] = [];
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => { 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); current.push(cert);
map.set(cert.ca_certificate_id, current); map.set(cert.caCertificateId, current);
return map; return map;
}, new Map()); }, new Map());
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({ const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
@@ -168,7 +168,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
usedBy: usageMap.get(cert.id) ?? [], usedBy: usageMap.get(cert.id) ?? [],
}); });
} else { } 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"; } from "./actions";
type GroupMember = { type GroupMember = {
user_id: number; userId: number;
email: string; email: string;
name: string | null; name: string | null;
created_at: string; createdAt: string;
}; };
type Group = { type Group = {
@@ -28,8 +28,8 @@ type Group = {
name: string; name: string;
description: string | null; description: string | null;
members: GroupMember[]; members: GroupMember[];
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
type UserEntry = { type UserEntry = {
@@ -50,7 +50,7 @@ export default function GroupsClient({ groups, users }: Props) {
const [addMemberGroupId, setAddMemberGroupId] = useState<number | null>(null); const [addMemberGroupId, setAddMemberGroupId] = useState<number | null>(null);
function getAvailableUsers(group: Group): UserEntry[] { 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)); return users.filter((u) => !memberIds.has(u.id));
} }
@@ -204,7 +204,7 @@ export default function GroupsClient({ groups, users }: Props) {
<div className="space-y-1"> <div className="space-y-1">
{group.members.map((member) => ( {group.members.map((member) => (
<div <div
key={member.user_id} key={member.userId}
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50" className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -223,7 +223,7 @@ export default function GroupsClient({ groups, users }: Props) {
size="icon" size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive" className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={async () => { onClick={async () => {
await removeGroupMemberAction(group.id, member.user_id); await removeGroupMemberAction(group.id, member.userId);
router.refresh(); router.refresh();
}} }}
title="Remove member" title="Remove member"

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
hosts={hosts} hosts={hosts}
pagination={{ total, page, perPage: PER_PAGE }} pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""} 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} isAdmin={true}
recentEvents={recentEventsRaw.map((event) => ({ recentEvents={recentEventsRaw.map((event) => ({
summary: event.summary ?? `${event.action} on ${event.entityType}`, 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator"; 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 { 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 type { ApiToken } from "@/lib/models/api-tokens";
import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions"; import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions";
@@ -26,11 +26,11 @@ interface UserData {
id: number; id: number;
email: string; email: string;
name: string | null; name: string | null;
provider: string; provider: string | null;
subject: string; subject: string | null;
password_hash: string | null; passwordHash: string | null;
role: string; role: string;
avatar_url: string | null; avatarUrl: string | null;
} }
interface ProfileClientProps { interface ProfileClientProps {
@@ -48,11 +48,11 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false); 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 [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const hasPassword = !!user.password_hash; const hasPassword = !!user.passwordHash;
const hasOAuth = user.provider !== "credentials"; const hasOAuth = user.provider !== "credentials";
const handlePasswordChange = async () => { const handlePasswordChange = async () => {
@@ -158,9 +158,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
} }
// Now initiate OAuth flow // Now initiate OAuth flow
await signIn(providerId, { await authClient.signIn.social({ provider: providerId, callbackURL: "/profile" });
callbackUrl: "/profile"
});
} catch { } catch {
setError("An error occurred while linking OAuth"); setError("An error occurred while linking OAuth");
setLoading(false); setLoading(false);
@@ -382,7 +380,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<div> <div>
<p className="text-sm text-muted-foreground">Authentication Method</p> <p className="text-sm text-muted-foreground">Authentication Method</p>
<Badge variant={user.provider === "credentials" ? "secondary" : "default"}> <Badge variant={user.provider === "credentials" ? "secondary" : "default"}>
{getProviderName(user.provider)} {getProviderName(user.provider ?? "")}
</Badge> </Badge>
</div> </div>
@@ -442,7 +440,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{hasOAuth ? ( {hasOAuth ? (
<div> <div>
<p className="text-sm text-muted-foreground mb-2"> <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> </p>
{hasPassword ? ( {hasPassword ? (
@@ -523,7 +521,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
{apiTokens.length > 0 && ( {apiTokens.length > 0 && (
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden"> <div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
{apiTokens.map((token) => { {apiTokens.map((token) => {
const expired = isExpired(token.expires_at); const expired = isExpired(token.expiresAt);
return ( return (
<div <div
key={token.id} key={token.id}
@@ -543,15 +541,15 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
</div> </div>
<div className="flex flex-wrap gap-x-3 gap-y-0"> <div className="flex flex-wrap gap-x-3 gap-y-0">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Created {formatDate(token.created_at)} Created {formatDate(token.createdAt)}
</p> </p>
<p className="text-xs text-muted-foreground flex items-center gap-1"> <p className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
Used {formatDate(token.last_used_at)} Used {formatDate(token.lastUsedAt)}
</p> </p>
{token.expires_at && ( {token.expiresAt && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{expired ? "Expired" : "Expires"} {formatDate(token.expires_at)} {expired ? "Expired" : "Expires"} {formatDate(token.expiresAt)}
</p> </p>
)} )}
</div> </div>
@@ -656,7 +654,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
<DialogHeader> <DialogHeader>
<DialogTitle>Unlink OAuth Account</DialogTitle> <DialogTitle>Unlink OAuth Account</DialogTitle>
<DialogDescription> <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. You will only be able to sign in with your username and password after this.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

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

View File

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

View File

@@ -497,7 +497,7 @@ export async function createProxyHostAction(
const session = await requireAdmin(); const session = await requireAdmin();
const userId = Number(session.user.id); const userId = Number(session.user.id);
// Parse certificate_id safely // Parse certificateId safely
const parsedCertificateId = parseCertificateId(formData.get("certificate_id")); const parsedCertificateId = parseCertificateId(formData.get("certificate_id"));
// Validate certificate exists and get sanitized value // Validate certificate exists and get sanitized value
@@ -516,25 +516,25 @@ export async function createProxyHostAction(
name: String(formData.get("name") ?? "Untitled"), name: String(formData.get("name") ?? "Untitled"),
domains: parseCsv(formData.get("domains")), domains: parseCsv(formData.get("domains")),
upstreams: parseUpstreams(formData.get("upstreams")), upstreams: parseUpstreams(formData.get("upstreams")),
certificate_id: certificateId, certificateId: certificateId,
access_list_id: parseAccessListId(formData.get("access_list_id")), accessListId: parseAccessListId(formData.get("access_list_id")),
ssl_forced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined, sslForced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")), hstsSubdomains: parseCheckbox(formData.get("hsts_subdomains")),
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")), skipHttpsHostnameValidation: parseCheckbox(formData.get("skip_https_hostname_validation")),
enabled: parseCheckbox(formData.get("enabled")), enabled: parseCheckbox(formData.get("enabled")),
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")), customPreHandlersJson: parseOptionalText(formData.get("custom_pre_handlers_json")),
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")), customReverseProxyJson: parseOptionalText(formData.get("custom_reverse_proxy_json")),
authentik: parseAuthentikConfig(formData), authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData), cpmForwardAuth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData), loadBalancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData), dnsResolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData), ...parseGeoBlockConfig(formData),
...parseWafConfig(formData), ...parseWafConfig(formData),
mtls: parseMtlsConfig(formData), mtls: parseMtlsConfig(formData),
redirects: parseRedirectsConfig(formData), redirects: parseRedirectsConfig(formData),
rewrite: parseRewriteConfig(formData), rewrite: parseRewriteConfig(formData),
location_rules: parseLocationRulesConfig(formData), locationRules: parseLocationRulesConfig(formData),
}, },
userId userId
); );
@@ -542,7 +542,7 @@ export async function createProxyHostAction(
// Save forward auth access if CPM forward auth is enabled // 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 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); 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); 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, name: formData.get("name") ? String(formData.get("name")) : undefined,
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined, domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined, upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
certificate_id: certificateId, certificateId: certificateId,
access_list_id: formData.has("access_list_id") accessListId: formData.has("access_list_id")
? parseAccessListId(formData.get("access_list_id")) ? parseAccessListId(formData.get("access_list_id"))
: undefined, : undefined,
hsts_subdomains: boolField("hsts_subdomains"), hstsSubdomains: boolField("hsts_subdomains"),
skip_https_hostname_validation: boolField("skip_https_hostname_validation"), skipHttpsHostnameValidation: boolField("skip_https_hostname_validation"),
enabled: boolField("enabled"), 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")) ? parseOptionalText(formData.get("custom_pre_handlers_json"))
: undefined, : 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")) ? parseOptionalText(formData.get("custom_reverse_proxy_json"))
: undefined, : undefined,
authentik: parseAuthentikConfig(formData), authentik: parseAuthentikConfig(formData),
cpm_forward_auth: parseCpmForwardAuthConfig(formData), cpmForwardAuth: parseCpmForwardAuthConfig(formData),
load_balancer: parseLoadBalancerConfig(formData), loadBalancer: parseLoadBalancerConfig(formData),
dns_resolver: parseDnsResolverConfig(formData), dnsResolver: parseDnsResolverConfig(formData),
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData), upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
...parseGeoBlockConfig(formData), ...parseGeoBlockConfig(formData),
...parseWafConfig(formData), ...parseWafConfig(formData),
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined, mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined,
redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined, redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined,
rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(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 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 // 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( const faAccessEntries = await Promise.all(
faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => [])) faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => []))
); );
@@ -51,8 +51,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
faHosts.forEach((h, i) => { faHosts.forEach((h, i) => {
const entries = faAccessEntries[i]; const entries = faAccessEntries[i];
forwardAuthAccessMap[h.id] = { forwardAuthAccessMap[h.id] = {
userIds: entries.filter((e) => e.user_id !== null).map((e) => e.user_id!), userIds: entries.filter((e) => e.userId !== null).map((e) => e.userId!),
groupIds: entries.filter((e) => e.group_id !== null).map((e) => e.group_id!), groupIds: entries.filter((e) => e.groupId !== null).map((e) => e.groupId!),
}; };
}); });
@@ -78,7 +78,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
authentikDefaults={authentikDefaults} authentikDefaults={authentikDefaults}
pagination={{ total, page, perPage: PER_PAGE }} pagination={{ total, page, perPage: PER_PAGE }}
initialSearch={search ?? ""} initialSearch={search ?? ""}
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }} initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
mtlsRoles={mtlsRoles} mtlsRoles={mtlsRoles}
issuedClientCerts={issuedClientCerts} issuedClientCerts={issuedClientCerts}
forwardAuthUsers={forwardAuthUsers} 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 { useFormState } from "react-dom";
import { import {
Cloud, Globe, Network, Pin, Activity, Cloud, Globe, Network, Pin, Activity,
ScrollText, Settings2, UserCheck, MapPin, ScrollText, Settings2, UserCheck, MapPin, KeyRound,
} from "lucide-react"; } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -24,6 +24,8 @@ import type {
GeoBlockSettings, GeoBlockSettings,
} from "@/lib/settings"; } from "@/lib/settings";
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields"; import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
import OAuthProvidersSection from "./OAuthProvidersSection";
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
import { import {
updateCloudflareSettingsAction, updateCloudflareSettingsAction,
updateGeneralSettingsAction, 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" }, 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" }, 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" }, 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 ──────────────────────────────────────────────────────────────────── // ─── Props ────────────────────────────────────────────────────────────────────
@@ -134,6 +137,8 @@ type Props = {
dns: DnsSettings | null; dns: DnsSettings | null;
upstreamDnsResolution: UpstreamDnsResolutionSettings | null; upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
globalGeoBlock?: GeoBlockSettings | null; globalGeoBlock?: GeoBlockSettings | null;
oauthProviders: OAuthProvider[];
baseUrl: string;
instanceSync: { instanceSync: {
mode: "standalone" | "master" | "slave"; mode: "standalone" | "master" | "slave";
modeFromEnv: boolean; modeFromEnv: boolean;
@@ -156,10 +161,10 @@ type Props = {
instances: Array<{ instances: Array<{
id: number; id: number;
name: string; name: string;
base_url: string; baseUrl: string;
enabled: boolean; enabled: boolean;
last_sync_at: string | null; lastSyncAt: string | null;
last_sync_error: string | null; lastSyncError: string | null;
}>; }>;
envInstances: Array<{ envInstances: Array<{
name: string; name: string;
@@ -180,6 +185,8 @@ export default function SettingsClient({
dns, dns,
upstreamDnsResolution, upstreamDnsResolution,
globalGeoBlock, globalGeoBlock,
oauthProviders,
baseUrl,
instanceSync instanceSync
}: Props) { }: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null); const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
@@ -376,12 +383,12 @@ export default function SettingsClient({
> >
<div> <div>
<p className="text-sm font-semibold">{instance.name}</p> <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"> <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> </span>
{instance.last_sync_error && ( {instance.lastSyncError && (
<span className="block text-xs text-destructive">{instance.last_sync_error}</span> <span className="block text-xs text-destructive">{instance.lastSyncError}</span>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -853,6 +860,16 @@ export default function SettingsClient({
</div> </div>
</form> </form>
</SettingSection> </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> </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> { export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise<ActionResult> {
try { try {
const session = await requireAdmin(); 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 { 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 { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
import { listInstances } from "@/src/lib/models/instances"; 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"; import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() { export default async function SettingsPage() {
@@ -11,7 +13,7 @@ export default async function SettingsPage() {
const modeFromEnv = isInstanceModeFromEnv(); const modeFromEnv = isInstanceModeFromEnv();
const tokenFromEnv = isSyncTokenFromEnv(); 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(), getGeneralSettings(),
getCloudflareSettings(), getCloudflareSettings(),
getAuthentikSettings(), getAuthentikSettings(),
@@ -21,6 +23,7 @@ export default async function SettingsPage() {
getUpstreamDnsResolutionSettings(), getUpstreamDnsResolutionSettings(),
getInstanceMode(), getInstanceMode(),
getGeoBlockSettings(), getGeoBlockSettings(),
listOAuthProviders(),
]); ]);
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] = const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
@@ -57,6 +60,8 @@ export default async function SettingsPage() {
dns={dns} dns={dns}
upstreamDnsResolution={upstreamDnsResolution} upstreamDnsResolution={upstreamDnsResolution}
globalGeoBlock={globalGeoBlock} globalGeoBlock={globalGeoBlock}
oauthProviders={oauthProviders}
baseUrl={config.baseUrl}
instanceSync={{ instanceSync={{
mode: instanceMode, mode: instanceMode,
modeFromEnv, modeFromEnv,

View File

@@ -22,12 +22,12 @@ type UserEntry = {
email: string; email: string;
name: string | null; name: string | null;
role: "admin" | "user" | "viewer"; role: "admin" | "user" | "viewer";
provider: string; provider: string | null;
subject: string; subject: string | null;
avatar_url: string | null; avatarUrl: string | null;
status: string; status: string;
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
type Props = { type Props = {

View File

@@ -7,6 +7,6 @@ export default async function UsersPage() {
const allUsers = await listUsers(); const allUsers = await listUsers();
// Strip password hashes before sending to client // Strip password hashes before sending to client
// eslint-disable-next-line @typescript-eslint/no-unused-vars // 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} />; 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 { NextRequest, NextResponse } from "next/server";
import { signOut, checkSameOrigin } from "@/src/lib/auth"; 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) { export async function POST(request: NextRequest) {
const originCheck = checkSameOrigin(request); const originCheck = checkSameOrigin(request);
if (originCheck) return originCheck; 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 has a password, verify current password
if (user.password_hash) { if (user.passwordHash) {
if (!currentPassword) { if (!currentPassword) {
return NextResponse.json( return NextResponse.json(
{ error: "Current password is required" }, { 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) { if (!isValid) {
registerFailedAttempt(rateLimitKey); registerFailedAttempt(rateLimitKey);
return NextResponse.json( return NextResponse.json(
@@ -90,10 +90,10 @@ export async function POST(request: NextRequest) {
// Audit log // Audit log
await createAuditEvent({ await createAuditEvent({
userId, userId,
action: user.password_hash ? "password_changed" : "password_set", action: user.passwordHash ? "password_changed" : "password_set",
entityType: "user", entityType: "user",
entityId: userId, 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({ return NextResponse.json({

View File

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

View File

@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
// Update user avatar // Update user avatar
const updatedUser = await updateUserProfile(userId, { const updatedUser = await updateUserProfile(userId, {
avatar_url: avatarUrl avatarUrl: avatarUrl
}); });
if (!updatedUser) { if (!updatedUser) {
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
avatarUrl: updatedUser.avatar_url avatarUrl: updatedUser.avatarUrl
}); });
} catch (error) { } catch (error) {
console.error("Avatar update error:", 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: { User: {
type: "object", type: "object",
description: "User account (password_hash is never exposed)", description: "User account (passwordHash is never exposed)",
properties: { properties: {
id: { type: "integer" }, id: { type: "integer" },
email: { type: "string" }, email: { type: "string" },
@@ -1990,12 +1990,12 @@ const spec = {
role: { type: "string", enum: ["admin", "user", "viewer"] }, role: { type: "string", enum: ["admin", "user", "viewer"] },
provider: { type: "string", example: "credentials" }, provider: { type: "string", example: "credentials" },
subject: { type: "string" }, subject: { type: "string" },
avatar_url: { type: ["string", "null"] }, avatarUrl: { type: ["string", "null"] },
status: { type: "string", enum: ["active", "inactive"] }, status: { type: "string", enum: ["active", "inactive"] },
created_at: { type: "string", format: "date-time" }, createdAt: { type: "string", format: "date-time" },
updated_at: { 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: { AuditLogEvent: {
type: "object", 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"; import { getUserById, updateUserProfile, updateUserRole, updateUserStatus, deleteUser } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) { function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user; const { passwordHash: _, ...rest } = user;
void _; void _;
return rest; return rest;
} }
@@ -62,9 +62,9 @@ export async function PUT(
const profileFields: Record<string, unknown> = {}; const profileFields: Record<string, unknown> = {};
if (body.email !== undefined) profileFields.email = body.email; if (body.email !== undefined) profileFields.email = body.email;
if (body.name !== undefined) profileFields.name = body.name; 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) { 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); 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"; import { listUsers } from "@/src/lib/models/user";
function stripPasswordHash(user: Record<string, unknown>) { function stripPasswordHash(user: Record<string, unknown>) {
const { password_hash: _, ...rest } = user; const { passwordHash: _, ...rest } = user;
void _; void _;
return rest; return rest;
} }

View File

@@ -24,6 +24,7 @@
"apexcharts": "^5.10.6", "apexcharts": "^5.10.6",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.6.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -35,7 +36,6 @@
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"maxmind": "^5.0.6", "maxmind": "^5.0.6",
"next": "^16.2.3", "next": "^16.2.3",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.4.0", "node-forge": "^1.4.0",
"postcss": "^8.5.9", "postcss": "^8.5.9",
@@ -77,8 +77,6 @@
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@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/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=="], "@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=="], "@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": ["@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=="], "@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=="], "@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/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=="], "@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=="], "@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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "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=="], "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=="], "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=="], "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=="], "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=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "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": ["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=="], "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=="], "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=="], "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-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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/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/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=="], "@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=="], "@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=="], "@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=="], "@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-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=="], "@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=="], "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/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=="], "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=="], "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=="], "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=="], "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, "when": 1775700000000,
"tag": "0019_drop_analytics_tables", "tag": "0019_drop_analytics_tables",
"breakpoints": true "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", "apexcharts": "^5.10.6",
"autoprefixer": "^10.4.27", "autoprefixer": "^10.4.27",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.6.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@@ -50,7 +51,6 @@
"maplibre-gl": "^5.22.0", "maplibre-gl": "^5.22.0",
"maxmind": "^5.0.6", "maxmind": "^5.0.6",
"next": "^16.2.3", "next": "^16.2.3",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-forge": "^1.4.0", "node-forge": "^1.4.0",
"postcss": "^8.5.9", "postcss": "^8.5.9",

View File

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

View File

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

View File

@@ -68,20 +68,20 @@ function L4HostForm({
const [enabled, setEnabled] = useState(initialData?.enabled ?? true); const [enabled, setEnabled] = useState(initialData?.enabled ?? true);
const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp"); const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp");
const [matcherType, setMatcherType] = useState( const [matcherType, setMatcherType] = useState(
initialData?.matcher_type ?? "none" initialData?.matcherType ?? "none"
); );
const defaultLbAccordion = initialData?.load_balancer?.enabled const defaultLbAccordion = initialData?.loadBalancer?.enabled
? "load-balancer" ? "load-balancer"
: undefined; : undefined;
const defaultDnsAccordion = initialData?.dns_resolver?.enabled const defaultDnsAccordion = initialData?.dnsResolver?.enabled
? "dns-resolver" ? "dns-resolver"
: undefined; : undefined;
const defaultGeoblockAccordion = initialData?.geoblock?.enabled const defaultGeoblockAccordion = initialData?.geoblock?.enabled
? "geoblock" ? "geoblock"
: undefined; : undefined;
const defaultUpstreamDnsAccordion = const defaultUpstreamDnsAccordion =
initialData?.upstream_dns_resolution?.enabled === true initialData?.upstreamDnsResolution?.enabled === true
? "upstream-dns" ? "upstream-dns"
: undefined; : undefined;
@@ -163,7 +163,7 @@ function L4HostForm({
id="listen_address" id="listen_address"
name="listen_address" name="listen_address"
placeholder=":5432" placeholder=":5432"
defaultValue={initialData?.listen_address ?? ""} defaultValue={initialData?.listenAddress ?? ""}
required required
/> />
</FormField> </FormField>
@@ -220,7 +220,7 @@ function L4HostForm({
id="matcher_value" id="matcher_value"
name="matcher_value" name="matcher_value"
placeholder="db.example.com, api.example.com" placeholder="db.example.com, api.example.com"
defaultValue={initialData?.matcher_value?.join(", ") ?? ""} defaultValue={initialData?.matcherValue?.join(", ") ?? ""}
required required
/> />
</FormField> </FormField>
@@ -231,7 +231,7 @@ function L4HostForm({
<Switch <Switch
id="tls_termination" id="tls_termination"
name="tls_termination" name="tls_termination"
defaultChecked={initialData?.tls_termination ?? false} defaultChecked={initialData?.tlsTermination ?? false}
/> />
<Label htmlFor="tls_termination">TLS Termination</Label> <Label htmlFor="tls_termination">TLS Termination</Label>
</div> </div>
@@ -241,7 +241,7 @@ function L4HostForm({
<Switch <Switch
id="proxy_protocol_receive" id="proxy_protocol_receive"
name="proxy_protocol_receive" name="proxy_protocol_receive"
defaultChecked={initialData?.proxy_protocol_receive ?? false} defaultChecked={initialData?.proxyProtocolReceive ?? false}
/> />
<Label htmlFor="proxy_protocol_receive"> <Label htmlFor="proxy_protocol_receive">
Accept inbound PROXY protocol Accept inbound PROXY protocol
@@ -254,7 +254,7 @@ function L4HostForm({
</Label> </Label>
<Select <Select
name="proxy_protocol_version" name="proxy_protocol_version"
defaultValue={initialData?.proxy_protocol_version ?? "__none__"} defaultValue={initialData?.proxyProtocolVersion ?? "__none__"}
> >
<SelectTrigger id="proxy_protocol_version"> <SelectTrigger id="proxy_protocol_version">
<SelectValue placeholder="None" /> <SelectValue placeholder="None" />
@@ -292,7 +292,7 @@ function L4HostForm({
id="lb_enabled" id="lb_enabled"
name="lb_enabled" name="lb_enabled"
defaultChecked={ defaultChecked={
initialData?.load_balancer?.enabled ?? false initialData?.loadBalancer?.enabled ?? false
} }
/> />
<Label htmlFor="lb_enabled">Enable Load Balancing</Label> <Label htmlFor="lb_enabled">Enable Load Balancing</Label>
@@ -302,7 +302,7 @@ function L4HostForm({
<Select <Select
name="lb_policy" name="lb_policy"
defaultValue={ defaultValue={
initialData?.load_balancer?.policy ?? "random" initialData?.loadBalancer?.policy ?? "random"
} }
> >
<SelectTrigger id="lb_policy"> <SelectTrigger id="lb_policy">
@@ -325,7 +325,7 @@ function L4HostForm({
name="lb_try_duration" name="lb_try_duration"
placeholder="5s" placeholder="5s"
defaultValue={ defaultValue={
initialData?.load_balancer?.tryDuration ?? "" initialData?.loadBalancer?.tryDuration ?? ""
} }
/> />
</FormField> </FormField>
@@ -335,7 +335,7 @@ function L4HostForm({
name="lb_try_interval" name="lb_try_interval"
placeholder="250ms" placeholder="250ms"
defaultValue={ defaultValue={
initialData?.load_balancer?.tryInterval ?? "" initialData?.loadBalancer?.tryInterval ?? ""
} }
/> />
</FormField> </FormField>
@@ -344,7 +344,7 @@ function L4HostForm({
id="lb_retries" id="lb_retries"
name="lb_retries" name="lb_retries"
type="number" type="number"
defaultValue={initialData?.load_balancer?.retries ?? ""} defaultValue={initialData?.loadBalancer?.retries ?? ""}
/> />
</FormField> </FormField>
@@ -361,7 +361,7 @@ function L4HostForm({
id="lb_active_health_enabled" id="lb_active_health_enabled"
name="lb_active_health_enabled" name="lb_active_health_enabled"
defaultChecked={ defaultChecked={
initialData?.load_balancer?.activeHealthCheck?.enabled ?? initialData?.loadBalancer?.activeHealthCheck?.enabled ??
false false
} }
/> />
@@ -378,7 +378,7 @@ function L4HostForm({
name="lb_active_health_port" name="lb_active_health_port"
type="number" type="number"
defaultValue={ defaultValue={
initialData?.load_balancer?.activeHealthCheck?.port ?? "" initialData?.loadBalancer?.activeHealthCheck?.port ?? ""
} }
/> />
</FormField> </FormField>
@@ -388,7 +388,7 @@ function L4HostForm({
name="lb_active_health_interval" name="lb_active_health_interval"
placeholder="30s" placeholder="30s"
defaultValue={ defaultValue={
initialData?.load_balancer?.activeHealthCheck?.interval ?? initialData?.loadBalancer?.activeHealthCheck?.interval ??
"" ""
} }
/> />
@@ -399,7 +399,7 @@ function L4HostForm({
name="lb_active_health_timeout" name="lb_active_health_timeout"
placeholder="5s" placeholder="5s"
defaultValue={ defaultValue={
initialData?.load_balancer?.activeHealthCheck?.timeout ?? "" initialData?.loadBalancer?.activeHealthCheck?.timeout ?? ""
} }
/> />
</FormField> </FormField>
@@ -417,7 +417,7 @@ function L4HostForm({
id="lb_passive_health_enabled" id="lb_passive_health_enabled"
name="lb_passive_health_enabled" name="lb_passive_health_enabled"
defaultChecked={ defaultChecked={
initialData?.load_balancer?.passiveHealthCheck?.enabled ?? initialData?.loadBalancer?.passiveHealthCheck?.enabled ??
false false
} }
/> />
@@ -434,7 +434,7 @@ function L4HostForm({
name="lb_passive_health_fail_duration" name="lb_passive_health_fail_duration"
placeholder="30s" placeholder="30s"
defaultValue={ defaultValue={
initialData?.load_balancer?.passiveHealthCheck initialData?.loadBalancer?.passiveHealthCheck
?.failDuration ?? "" ?.failDuration ?? ""
} }
/> />
@@ -445,7 +445,7 @@ function L4HostForm({
name="lb_passive_health_max_fails" name="lb_passive_health_max_fails"
type="number" type="number"
defaultValue={ defaultValue={
initialData?.load_balancer?.passiveHealthCheck?.maxFails ?? initialData?.loadBalancer?.passiveHealthCheck?.maxFails ??
"" ""
} }
/> />
@@ -459,7 +459,7 @@ function L4HostForm({
name="lb_passive_health_unhealthy_latency" name="lb_passive_health_unhealthy_latency"
placeholder="5s" placeholder="5s"
defaultValue={ defaultValue={
initialData?.load_balancer?.passiveHealthCheck initialData?.loadBalancer?.passiveHealthCheck
?.unhealthyLatency ?? "" ?.unhealthyLatency ?? ""
} }
/> />
@@ -493,7 +493,7 @@ function L4HostForm({
<Switch <Switch
id="dns_enabled" id="dns_enabled"
name="dns_enabled" name="dns_enabled"
defaultChecked={initialData?.dns_resolver?.enabled ?? false} defaultChecked={initialData?.dnsResolver?.enabled ?? false}
/> />
<Label htmlFor="dns_enabled">Enable Custom DNS</Label> <Label htmlFor="dns_enabled">Enable Custom DNS</Label>
</div> </div>
@@ -507,7 +507,7 @@ function L4HostForm({
name="dns_resolvers" name="dns_resolvers"
placeholder={"1.1.1.1\n8.8.8.8"} placeholder={"1.1.1.1\n8.8.8.8"}
defaultValue={ defaultValue={
initialData?.dns_resolver?.resolvers?.join("\n") ?? "" initialData?.dnsResolver?.resolvers?.join("\n") ?? ""
} }
rows={2} rows={2}
/> />
@@ -522,7 +522,7 @@ function L4HostForm({
name="dns_fallbacks" name="dns_fallbacks"
placeholder="8.8.4.4" placeholder="8.8.4.4"
defaultValue={ defaultValue={
initialData?.dns_resolver?.fallbacks?.join("\n") ?? "" initialData?.dnsResolver?.fallbacks?.join("\n") ?? ""
} }
rows={1} rows={1}
/> />
@@ -532,7 +532,7 @@ function L4HostForm({
id="dns_timeout" id="dns_timeout"
name="dns_timeout" name="dns_timeout"
placeholder="5s" placeholder="5s"
defaultValue={initialData?.dns_resolver?.timeout ?? ""} defaultValue={initialData?.dnsResolver?.timeout ?? ""}
/> />
</FormField> </FormField>
</div> </div>
@@ -571,7 +571,7 @@ function L4HostForm({
<Label htmlFor="geoblock_mode">Mode</Label> <Label htmlFor="geoblock_mode">Mode</Label>
<Select <Select
name="geoblock_mode" name="geoblock_mode"
defaultValue={initialData?.geoblock_mode ?? "merge"} defaultValue={initialData?.geoblockMode ?? "merge"}
> >
<SelectTrigger id="geoblock_mode"> <SelectTrigger id="geoblock_mode">
<SelectValue /> <SelectValue />
@@ -752,9 +752,9 @@ function L4HostForm({
<Select <Select
name="upstream_dns_resolution_mode" name="upstream_dns_resolution_mode"
defaultValue={ defaultValue={
initialData?.upstream_dns_resolution?.enabled === true initialData?.upstreamDnsResolution?.enabled === true
? "enabled" ? "enabled"
: initialData?.upstream_dns_resolution?.enabled === false : initialData?.upstreamDnsResolution?.enabled === false
? "disabled" ? "disabled"
: "inherit" : "inherit"
} }
@@ -778,7 +778,7 @@ function L4HostForm({
<Select <Select
name="upstream_dns_resolution_family" name="upstream_dns_resolution_family"
defaultValue={ defaultValue={
initialData?.upstream_dns_resolution?.family ?? "inherit" initialData?.upstreamDnsResolution?.family ?? "inherit"
} }
> >
<SelectTrigger id="upstream_dns_resolution_family"> <SelectTrigger id="upstream_dns_resolution_family">
@@ -948,7 +948,7 @@ export function DeleteL4HostDialog({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground w-20 shrink-0">Listen</span> <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>
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-muted-foreground w-20 shrink-0">Upstreams</span> <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) { export function MtlsRolesTab({ roles, issuedCerts, search }: Props) {
const [createOpen, setCreateOpen] = useState(false); 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 => const filtered = roles.filter(r =>
!search || !search ||
@@ -128,7 +128,7 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
const loadAssignments = useCallback(() => { const loadAssignments = useCallback(() => {
fetch(`/api/v1/mtls-roles/${role.id}`) fetch(`/api/v1/mtls-roles/${role.id}`)
.then(r => r.ok ? r.json() : { certificate_ids: [] }) .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)); .catch(() => setLoaded(true));
}, [role.id]); }, [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; }); setAssignedIds(prev => { const next = new Set(prev); next.delete(certId); return next; });
} else { } else {
await fetch(`/api/v1/mtls-roles/${role.id}/certificates`, { 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)); 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"> <div className="flex items-center gap-2.5">
<Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} /> <Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} />
<div> <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"> <p className="text-xs text-muted-foreground">
expires {new Date(cert.valid_to).toLocaleDateString()} expires {new Date(cert.validTo).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
import { useState } from "react"; import { useState } from "react";
type ToggleSetting = { type ToggleSetting = {
name: "hsts_subdomains" | "skip_https_hostname_validation"; name: "hstsSubdomains" | "skipHttpsHostnameValidation";
label: string; label: string;
description: string; description: string;
defaultChecked: boolean; defaultChecked: boolean;
@@ -21,8 +21,8 @@ export function SettingsToggles({
enabled = true enabled = true
}: SettingsTogglesProps) { }: SettingsTogglesProps) {
const [values, setValues] = useState({ const [values, setValues] = useState({
hsts_subdomains: hstsSubdomains, hstsSubdomains: hstsSubdomains,
skip_https_hostname_validation: skipHttpsValidation, skipHttpsHostnameValidation: skipHttpsValidation,
enabled: enabled enabled: enabled
}); });
@@ -32,16 +32,16 @@ export function SettingsToggles({
const settings: ToggleSetting[] = [ const settings: ToggleSetting[] = [
{ {
name: "hsts_subdomains", name: "hstsSubdomains",
label: "HSTS Subdomains", label: "HSTS Subdomains",
description: "Include subdomains in the Strict-Transport-Security header", 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", label: "Skip HTTPS Validation",
description: "Skip SSL certificate hostname verification for backend connections", 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({ export function UpstreamDnsResolutionFields({
upstreamDnsResolution upstreamDnsResolution
}: { }: {
upstreamDnsResolution?: ProxyHost["upstream_dns_resolution"] | null; upstreamDnsResolution?: ProxyHost["upstreamDnsResolution"] | null;
}) { }) {
const mode = toResolutionMode(upstreamDnsResolution?.enabled); const mode = toResolutionMode(upstreamDnsResolution?.enabled);
const family = toFamilyMode(upstreamDnsResolution?.family); 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 { type NextRequest, NextResponse } from "next/server";
import Credentials from "next-auth/providers/credentials"; import { getAuth } from "./auth-server";
import type { OAuthConfig } from "next-auth/providers"; import { getUserById } from "./models/user";
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";
declare module "next-auth" { export type Session = {
interface Session { user: {
user: { id: string;
id: string; email: string;
role: string; name: string | null;
provider?: string; role: string;
} & DefaultSession["user"]; provider?: string;
image?: string | null;
};
};
/**
* Get the current session, optionally from a specific request.
*
* - `auth()` — uses `headers()` from next/headers (server components, route handlers)
* - `auth(req)` — uses request headers (middleware)
*
* Returns `Session | null`. The user's role is always fetched fresh from the database
* so that role changes (e.g. demotion) take effect immediately.
*/
export async function auth(req?: NextRequest): Promise<Session | null> {
const hdrs = req
? req.headers
: (await import("next/headers")).headers();
// headers() in Next.js 15+ returns a Promise
const resolvedHeaders = hdrs instanceof Promise ? await hdrs : hdrs;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let betterAuthSession: any = null;
try {
betterAuthSession = await getAuth().api.getSession({
headers: resolvedHeaders,
});
} catch {
return null;
} }
interface User { if (!betterAuthSession?.user) {
return null;
}
const baUser = betterAuthSession.user as {
id: string | number;
name?: string | null;
email: string;
image?: string | null;
role?: string; role?: string;
provider?: string; provider?: string;
} status?: string;
} avatarUrl?: string | null;
subject?: string;
};
const userId = typeof baUser.id === "string" ? Number(baUser.id) : baUser.id;
// Credentials provider that checks against hashed passwords in the database // Always fetch current role/status from database to reflect changes immediately
function createCredentialsProvider() { const currentUser = await getUserById(userId);
return Credentials({ if (!currentUser || currentUser.status !== "active") {
id: "credentials", return null;
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
const username = credentials?.username ? String(credentials.username).trim() : "";
const password = credentials?.password ? String(credentials.password) : "";
if (!username || !password) {
return null;
}
// Look up user in database by email (constructed from username)
const email = `${username}@localhost`;
const user = await db.query.users.findFirst({
where: (table, operators) => operators.eq(table.email, email)
});
if (!user || user.status !== "active" || !user.passwordHash) {
return null;
}
// Verify password against hashed password in database
const isValidPassword = bcrypt.compareSync(password, user.passwordHash);
if (!isValidPassword) {
return null;
}
return {
id: user.id.toString(),
name: user.name ?? username,
email: user.email,
role: user.role
};
}
});
}
const credentialsProvider = createCredentialsProvider();
// Create OAuth providers based on configuration
function createOAuthProviders(): OAuthConfig<Record<string, unknown>>[] {
const providers: OAuthConfig<Record<string, unknown>>[] = [];
if (
config.oauth.enabled &&
config.oauth.clientId &&
config.oauth.clientSecret
) {
const oauthProvider: OAuthConfig<Record<string, unknown>> = {
id: "oauth2",
name: config.oauth.providerName,
type: "oidc",
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
issuer: config.oauth.issuer ?? undefined,
authorization: config.oauth.authorizationUrl ?? undefined,
token: config.oauth.tokenUrl ?? undefined,
userinfo: config.oauth.userinfoUrl ?? undefined,
// PKCE is the default for OIDC; state is added as defence-in-depth
checks: ["pkce", "state"],
profile(profile) {
const sub = typeof profile.sub === "string" ? profile.sub : undefined;
const id = typeof profile.id === "string" ? profile.id : undefined;
const name = typeof profile.name === "string" ? profile.name : undefined;
const preferredUsername =
typeof profile.preferred_username === "string" ? profile.preferred_username : undefined;
const email = typeof profile.email === "string" ? profile.email : undefined;
const picture = typeof profile.picture === "string" ? profile.picture : null;
const avatarUrl = typeof profile.avatar_url === "string" ? profile.avatar_url : null;
return {
id: sub ?? id,
name: name ?? preferredUsername ?? email,
email,
image: picture ?? avatarUrl,
};
},
};
providers.push(oauthProvider);
} }
return providers; return {
} user: {
id: String(currentUser.id),
export const { handlers, signIn, signOut, auth } = NextAuth({ email: currentUser.email,
providers: [credentialsProvider, ...createOAuthProviders()], name: currentUser.name,
session: { role: currentUser.role,
strategy: "jwt", provider: currentUser.provider || baUser.provider,
maxAge: 7 * 24 * 60 * 60, // 7 days image: currentUser.avatarUrl ?? (baUser.avatarUrl as string | null | undefined) ?? null,
},
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);
const currentUser = await getUserById(userId);
if (currentUser) {
session.user.role = currentUser.role;
session.user.image = currentUser.avatar_url ?? (token.image as string | null | undefined);
} else {
// User deleted from DB — deny access by clearing session
session.user.role = token.role as string;
session.user.image = token.image as string | null | undefined;
}
}
return session;
},
},
secret: config.sessionSecret,
// Only trust Host header when explicitly opted in or when NEXTAUTH_URL
// is set (operator has declared the canonical URL, so Host validation is moot).
trustHost: !!process.env.NEXTAUTH_TRUST_HOST || !!process.env.NEXTAUTH_URL,
basePath: "/api/auth",
});
/**
* Helper function to get the current session on the server.
*/
export async function getSession() {
return await auth();
} }
/** /**
* Helper function to require authentication, throwing if not authenticated. * Alias for auth() — get the current session on the server.
*/ */
export async function requireUser() { export async function getSession(): Promise<Session | null> {
return auth();
}
/**
* Require authentication. Redirects to /login if not authenticated.
*/
export async function requireUser(): Promise<Session> {
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
const { redirect } = await import("next/navigation"); const { redirect } = await import("next/navigation");
@@ -435,7 +96,10 @@ export async function requireUser() {
return session; 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(); const session = await requireUser();
if (session.user.role !== "admin") { if (session.user.role !== "admin") {
throw new Error("Administrator privileges required"); 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). * Defined here to avoid importing from models (which pulls in db.ts).
*/ */
export type MtlsAccessRuleLike = { export type MtlsAccessRuleLike = {
path_pattern: string; pathPattern: string;
allowed_role_ids: number[]; allowedRoleIds: number[];
allowed_cert_ids: number[]; allowedCertIds: number[];
deny_all: boolean; denyAll: boolean;
}; };
/** /**
@@ -177,14 +177,14 @@ export function resolveAllowedFingerprints(
): Set<string> { ): Set<string> {
const allowed = new 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); const fps = roleFingerprintMap.get(roleId);
if (fps) { if (fps) {
for (const fp of fps) allowed.add(fp); 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); const fp = certFingerprintMap.get(certId);
if (fp) allowed.add(fp); if (fp) allowed.add(fp);
} }
@@ -229,10 +229,10 @@ export function buildMtlsRbacSubroutes(
// Rules are already sorted by priority desc, path asc // Rules are already sorted by priority desc, path asc
for (const rule of accessRules) { for (const rule of accessRules) {
if (rule.deny_all) { if (rule.denyAll) {
// Explicit deny: any request matching this path gets 403 // Explicit deny: any request matching this path gets 403
subroutes.push({ subroutes.push({
match: [{ path: [rule.path_pattern] }], match: [{ path: [rule.pathPattern] }],
handle: [{ handle: [{
handler: "static_response", handler: "static_response",
status_code: "403", status_code: "403",
@@ -248,7 +248,7 @@ export function buildMtlsRbacSubroutes(
if (allowedFps.size === 0) { if (allowedFps.size === 0) {
// Rule exists but no certs match → deny all for this path // Rule exists but no certs match → deny all for this path
subroutes.push({ subroutes.push({
match: [{ path: [rule.path_pattern] }], match: [{ path: [rule.pathPattern] }],
handle: [{ handle: [{
handler: "static_response", handler: "static_response",
status_code: "403", status_code: "403",
@@ -262,14 +262,14 @@ export function buildMtlsRbacSubroutes(
// Allow route: path + fingerprint CEL match // Allow route: path + fingerprint CEL match
const celExpr = buildFingerprintCelExpression(allowedFps); const celExpr = buildFingerprintCelExpression(allowedFps);
subroutes.push({ subroutes.push({
match: [{ path: [rule.path_pattern], expression: celExpr }], match: [{ path: [rule.pathPattern], expression: celExpr }],
handle: [...baseHandlers, reverseProxyHandler], handle: [...baseHandlers, reverseProxyHandler],
terminal: true, terminal: true,
}); });
// Deny route: path matches but fingerprint didn't → 403 // Deny route: path matches but fingerprint didn't → 403
subroutes.push({ subroutes.push({
match: [{ path: [rule.path_pattern] }], match: [{ path: [rule.pathPattern] }],
handle: [{ handle: [{
handler: "static_response", handler: "static_response",
status_code: "403", status_code: "403",

View File

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

View File

@@ -1,6 +1,8 @@
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { drizzle } from "drizzle-orm/bun-sqlite"; import { drizzle } from "drizzle-orm/bun-sqlite";
import { migrate } from "drizzle-orm/bun-sqlite/migrator"; 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 { mkdirSync } from "node:fs";
import { dirname, isAbsolute, resolve as resolvePath } from "node:path"; import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
@@ -58,7 +60,7 @@ function ensureDirectoryFor(pathname: string) {
const globalForDrizzle = globalThis as GlobalForDrizzle; const globalForDrizzle = globalThis as GlobalForDrizzle;
const sqlite = export const sqlite =
globalForDrizzle.__SQLITE_CLIENT__ ?? globalForDrizzle.__SQLITE_CLIENT__ ??
(() => { (() => {
ensureDirectoryFor(sqlitePath); ensureDirectoryFor(sqlitePath);
@@ -70,7 +72,7 @@ if (process.env.NODE_ENV !== "production") {
} }
export const db = export const db =
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" }); globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema });
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalForDrizzle.__DRIZZLE_DB__ = db; 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 { schema };
export default db; export default db;

View File

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

View File

@@ -1,8 +1,9 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { randomUUID } from "node:crypto";
import db, { nowIso } from "./db"; import db, { nowIso } from "./db";
import { config } from "./config"; import { config } from "./config";
import { users } from "./db/schema"; import { users, accounts } from "./db/schema";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
/** /**
* Ensures the admin user from environment variables exists in the database. * Ensures the admin user from environment variables exists in the database.
@@ -37,9 +38,13 @@ export async function ensureAdminUser(): Promise<void> {
subject, subject,
passwordHash, passwordHash,
role: "admin", role: "admin",
username: config.adminUsername.toLowerCase(),
displayUsername: config.adminUsername,
updatedAt: now updatedAt: now
}) })
.where(eq(users.id, adminId)); .where(eq(users.id, adminId));
// Ensure credential account row exists for Better Auth
await ensureCredentialAccount(adminId, passwordHash);
console.log(`Updated admin user: ${config.adminUsername}`); console.log(`Updated admin user: ${config.adminUsername}`);
return; return;
} }
@@ -54,6 +59,8 @@ export async function ensureAdminUser(): Promise<void> {
role: "admin", role: "admin",
provider, provider,
subject, subject,
username: config.adminUsername.toLowerCase(),
displayUsername: config.adminUsername,
avatarUrl: null, avatarUrl: null,
status: "active", status: "active",
createdAt: now, createdAt: now,
@@ -61,4 +68,35 @@ export async function ensureAdminUser(): Promise<void> {
}); });
console.log(`Created admin user: ${config.adminUsername}`); 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 = { export type AccessListEntry = {
id: number; id: number;
username: string; username: string;
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
export type AccessList = { export type AccessList = {
@@ -17,8 +17,8 @@ export type AccessList = {
name: string; name: string;
description: string | null; description: string | null;
entries: AccessListEntry[]; entries: AccessListEntry[];
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
export type AccessListInput = { export type AccessListInput = {
@@ -34,8 +34,8 @@ function buildEntry(row: AccessListEntryRow): AccessListEntry {
return { return {
id: row.id, id: row.id,
username: row.username, username: row.username,
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)! updatedAt: toIso(row.updatedAt)!
}; };
} }
@@ -48,8 +48,8 @@ function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): Access
.slice() .slice()
.sort((a, b) => a.username.localeCompare(b.username)) .sort((a, b) => a.username.localeCompare(b.username))
.map(buildEntry), .map(buildEntry),
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)! updatedAt: toIso(row.updatedAt)!
}; };
} }

View File

@@ -7,10 +7,10 @@ import { NotFoundError } from "../api-auth";
export type ApiToken = { export type ApiToken = {
id: number; id: number;
name: string; name: string;
created_by: number; createdBy: number;
created_at: string; createdAt: string;
last_used_at: string | null; lastUsedAt: string | null;
expires_at: string | null; expiresAt: string | null;
}; };
type ApiTokenRow = typeof apiTokens.$inferSelect; type ApiTokenRow = typeof apiTokens.$inferSelect;
@@ -19,10 +19,10 @@ function toApiToken(row: ApiTokenRow): ApiToken {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
created_by: row.createdBy, createdBy: row.createdBy,
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
last_used_at: row.lastUsedAt ? toIso(row.lastUsedAt) : null, lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
expires_at: row.expiresAt ? toIso(row.expiresAt) : 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 = { export type AuditEvent = {
id: number; id: number;
user_id: number | null; userId: number | null;
action: string; action: string;
entity_type: string; entityType: string;
entity_id: number | null; entityId: number | null;
summary: string | null; summary: string | null;
created_at: string; createdAt: string;
}; };
// Escape LIKE metacharacters so user input is treated as literal text // Escape LIKE metacharacters so user input is treated as literal text
@@ -57,12 +57,12 @@ export async function listAuditEvents(
return events.map((event) => ({ return events.map((event) => ({
id: event.id, id: event.id,
user_id: event.userId, userId: event.userId,
action: event.action, action: event.action,
entity_type: event.entityType, entityType: event.entityType,
entity_id: event.entityId, entityId: event.entityId,
summary: event.summary, 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 = { export type CaCertificate = {
id: number; id: number;
name: string; name: string;
certificate_pem: string; certificatePem: string;
has_private_key: boolean; hasPrivateKey: boolean;
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
export type CaCertificateInput = { export type CaCertificateInput = {
name: string; name: string;
certificate_pem: string; certificatePem: string;
private_key_pem?: string; privateKeyPem?: string;
}; };
type CaCertificateRow = typeof caCertificates.$inferSelect; type CaCertificateRow = typeof caCertificates.$inferSelect;
@@ -34,10 +34,10 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
certificate_pem: row.certificatePem, certificatePem: row.certificatePem,
has_private_key: !!row.privateKeyPem, hasPrivateKey: !!row.privateKeyPem,
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)! updatedAt: toIso(row.updatedAt)!
}; };
} }
@@ -66,8 +66,8 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
.insert(caCertificates) .insert(caCertificates)
.values({ .values({
name: input.name.trim(), name: input.name.trim(),
certificatePem: input.certificate_pem.trim(), certificatePem: input.certificatePem.trim(),
privateKeyPem: input.private_key_pem?.trim() ?? null, privateKeyPem: input.privateKeyPem?.trim() ?? null,
createdBy: actorUserId, createdBy: actorUserId,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
@@ -100,8 +100,8 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
.update(caCertificates) .update(caCertificates)
.set({ .set({
name: input.name?.trim() ?? existing.name, name: input.name?.trim() ?? existing.name,
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem, certificatePem: input.certificatePem?.trim() ?? existing.certificatePem,
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}), ...(input.privateKeyPem !== undefined ? { privateKeyPem: input.privateKeyPem?.trim() ?? null } : {}),
updatedAt: now updatedAt: now
}) })
.where(eq(caCertificates.id, id)); .where(eq(caCertificates.id, id));

View File

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

View File

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

View File

@@ -8,15 +8,15 @@ export type Group = {
name: string; name: string;
description: string | null; description: string | null;
members: GroupMember[]; members: GroupMember[];
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
export type GroupMember = { export type GroupMember = {
user_id: number; userId: number;
email: string; email: string;
name: string | null; name: string | null;
created_at: string; createdAt: string;
}; };
export type GroupInput = { export type GroupInput = {
@@ -32,8 +32,8 @@ function toGroup(row: GroupRow, members: GroupMember[]): Group {
name: row.name, name: row.name,
description: row.description, description: row.description,
members, members,
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)! updatedAt: toIso(row.updatedAt)!
}; };
} }
@@ -61,10 +61,10 @@ export async function listGroups(): Promise<Group[]> {
for (const m of allMembers) { for (const m of allMembers) {
const bucket = membersByGroup.get(m.groupId) ?? []; const bucket = membersByGroup.get(m.groupId) ?? [];
bucket.push({ bucket.push({
user_id: m.userId, userId: m.userId,
email: m.email, email: m.email,
name: m.name, name: m.name,
created_at: toIso(m.createdAt)! createdAt: toIso(m.createdAt)!
}); });
membersByGroup.set(m.groupId, bucket); membersByGroup.set(m.groupId, bucket);
} }
@@ -97,10 +97,10 @@ export async function getGroup(id: number): Promise<Group | null> {
return toGroup( return toGroup(
group, group,
members.map((m) => ({ members.map((m) => ({
user_id: m.userId, userId: m.userId,
email: m.email, email: m.email,
name: m.name, 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 = { export type Instance = {
id: number; id: number;
name: string; name: string;
base_url: string; baseUrl: string;
enabled: boolean; enabled: boolean;
has_token: boolean; hasToken: boolean;
last_sync_at: string | null; lastSyncAt: string | null;
last_sync_error: string | null; lastSyncError: string | null;
created_at: string; createdAt: string;
updated_at: string; updatedAt: string;
}; };
export type InstanceInput = { export type InstanceInput = {
@@ -28,13 +28,13 @@ function toInstance(row: InstanceRow): Instance {
return { return {
id: row.id, id: row.id,
name: row.name, name: row.name,
base_url: row.baseUrl, baseUrl: row.baseUrl,
enabled: Boolean(row.enabled), enabled: Boolean(row.enabled),
has_token: row.apiToken.length > 0, hasToken: row.apiToken.length > 0,
last_sync_at: row.lastSyncAt ? toIso(row.lastSyncAt) : null, lastSyncAt: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
last_sync_error: row.lastSyncError ?? null, lastSyncError: row.lastSyncError ?? null,
created_at: toIso(row.createdAt)!, createdAt: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)! updatedAt: toIso(row.updatedAt)!
}; };
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { randomBytes } from "crypto"; import { randomBytes, randomUUID } from "crypto";
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import { config } from "../config"; import { config } from "../config";
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user"; import { findUserByEmail, getUserById } from "../models/user";
import db from "../db"; import db from "../db";
import { users, linkingTokens } from "../db/schema"; import { users, linkingTokens, accounts } from "../db/schema";
import { eq, lt } from "drizzle-orm"; import { and, eq, lt } from "drizzle-orm";
import { nowIso } from "../db"; import { nowIso } from "../db";
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
@@ -32,14 +32,13 @@ export async function decideLinkingStrategy(
providerAccountId: string, providerAccountId: string,
email: string email: string
): Promise<LinkingDecision> { ): Promise<LinkingDecision> {
// Check if OAuth account already exists // Check accounts table for existing OAuth connection
const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId); const existingAccount = await db.select().from(accounts).where(
if (existingOAuthUser) { and(eq(accounts.providerId, provider), eq(accounts.accountId, providerAccountId))
return { ).limit(1);
action: "signin_existing",
userId: existingOAuthUser.id, if (existingAccount.length > 0) {
reason: "OAuth account already linked" return { action: "signin_existing", userId: existingAccount[0].userId, reason: "OAuth account already linked" };
};
} }
// Check if email matches existing user // Check if email matches existing user
@@ -52,7 +51,7 @@ export async function decideLinkingStrategy(
} }
// User exists with this email // User exists with this email
if (existingEmailUser.password_hash) { if (existingEmailUser.passwordHash) {
// Has password - require manual linking with password verification // Has password - require manual linking with password verification
return { return {
action: "require_manual_link", action: "require_manual_link",
@@ -188,25 +187,24 @@ export async function verifyAndLinkOAuth(
providerAccountId: string providerAccountId: string
): Promise<boolean> { ): Promise<boolean> {
const user = await getUserById(userId); const user = await getUserById(userId);
if (!user || !user.password_hash) { if (!user || !user.passwordHash) {
return false; return false;
} }
// Verify password // Verify password
const isValid = bcrypt.compareSync(password, user.password_hash); const isValid = bcrypt.compareSync(password, user.passwordHash);
if (!isValid) { if (!isValid) {
return false; return false;
} }
// Update user to link OAuth // Insert OAuth account link
await db await db.insert(accounts).values({
.update(users) userId,
.set({ accountId: providerAccountId,
provider, providerId: provider,
subject: providerAccountId, createdAt: nowIso(),
updatedAt: nowIso() updatedAt: nowIso()
}) });
.where(eq(users.id, userId));
return true; 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) // 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 // 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; return false;
} }
// Update user to link OAuth // Insert OAuth account link
await db await db.insert(accounts).values({
.update(users) userId,
.set({ accountId: providerAccountId,
provider, providerId: provider,
subject: providerAccountId, createdAt: nowIso(),
avatarUrl: avatarUrl ?? user.avatar_url, updatedAt: nowIso()
updatedAt: nowIso() });
})
.where(eq(users.id, userId)); // Update avatar if provided
if (avatarUrl) {
await db
.update(users)
.set({ avatarUrl, updatedAt: nowIso() })
.where(eq(users.id, userId));
}
return true; return true;
} }
@@ -260,16 +264,22 @@ export async function linkOAuthAuthenticated(
return false; return false;
} }
// Update user to link OAuth // Insert OAuth account link
await db await db.insert(accounts).values({
.update(users) userId,
.set({ accountId: providerAccountId,
provider, providerId: provider,
subject: providerAccountId, createdAt: nowIso(),
avatarUrl: avatarUrl ?? user.avatar_url, updatedAt: nowIso()
updatedAt: nowIso() });
})
.where(eq(users.id, userId)); // Update avatar if provided
if (avatarUrl) {
await db
.update(users)
.set({ avatarUrl, updatedAt: nowIso() })
.where(eq(users.id, userId));
}
return true; 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 secret: cpm-test-secret
name: "CPM E2E Test" name: "CPM E2E Test"
redirectURIs: redirectURIs:
- "http://localhost:3000/api/auth/callback/oauth2" - "http://localhost:3000/api/auth/oauth2/callback/dex"
enablePasswordDB: true enablePasswordDB: true

View File

@@ -17,6 +17,8 @@ services:
OAUTH_TOKEN_URL: http://dex:5556/dex/token OAUTH_TOKEN_URL: http://dex:5556/dex/token
OAUTH_USERINFO_URL: http://dex:5556/dex/userinfo OAUTH_USERINFO_URL: http://dex:5556/dex/userinfo
OAUTH_ALLOW_AUTO_LINKING: "true" OAUTH_ALLOW_AUTO_LINKING: "true"
# Disable Better Auth rate limiting for E2E tests (many rapid auth requests)
AUTH_RATE_LIMIT_ENABLED: "false"
clickhouse: clickhouse:
environment: environment:
CLICKHOUSE_PASSWORD: "test-clickhouse-password-2026" 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}`); 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) { 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 // Dex shows a "Log in to dex" page with a link to the local (password) connector
// or goes straight to the login form // or goes straight to the login form
const loginLink = page.getByRole('link', { name: /log in with email/i }); const loginLink = page.getByRole('link', { name: /log in with email/i });
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) { if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
await loginLink.click(); 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 // Wait for the Dex login form to appear
await expect(page.getByRole('button', { name: /login/i })).toBeVisible({ timeout: 10_000 }); 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: /email/i }).fill(email);
await page.getByRole('textbox', { name: /password/i }).fill(password); await page.getByRole('textbox', { name: /password/i }).fill(password);
await page.getByRole('button', { name: /login/i }).click(); 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: [] } }); 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. * Perform OAuth login on the portal and return the callback URL.
* Does NOT navigate to the callback (test domains aren't DNS-resolvable). * 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', name: 'OAuth Forward Auth Test',
domains: [DOMAIN], domains: [DOMAIN],
upstreams: ['echo-server:8080'], upstreams: ['echo-server:8080'],
ssl_forced: false, sslForced: false,
cpm_forward_auth: { enabled: true }, cpmForwardAuth: { enabled: true },
}); });
expect(res.status()).toBe(201); expect(res.status()).toBe(201);
const host = await res.json(); 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 }) => { test('setup: trigger OAuth login for alice to create her user account', async ({ page }) => {
const ctx = await freshContext(page); await doOAuthLogin(page, ALICE);
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();
}
}); });
test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => { test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => {
const ctx = await freshContext(page); await doOAuthLogin(page, BOB);
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();
}
}); });
test('setup: find alice and bob user IDs', async ({ page }) => { 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', name: 'Functional Forward Auth Test',
domains: [DOMAIN], domains: [DOMAIN],
upstreams: ['echo-server:8080'], upstreams: ['echo-server:8080'],
ssl_forced: false, sslForced: false,
cpm_forward_auth: { enabled: true }, cpmForwardAuth: { enabled: true },
}, },
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL }, 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 expect(sortBtn).toBeVisible();
await sortBtn.click(); 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 }) => { 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 now = new Date().toISOString();
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email); const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
if (existing) { 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]); [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 { } else {
db.run( db.run(
"INSERT INTO users (email, name, password_hash, role, provider, subject, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'credentials', ?, 'active', ?, ?)", "INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
[email, "${username}", hash, "${role}", "${username}", now, now] [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], { 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(); await page.getByRole('button', { name: 'Sign in', exact: true }).click();
// The login client does router.replace('/') on success — wait for that // 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(); await page.close();
return context; return context;
} }
@@ -281,8 +293,12 @@ test.describe('Role-based access control', () => {
// ── Admin user — can access all pages ─────────────────────────────── // ── Admin user — can access all pages ───────────────────────────────
test('admin role: all dashboard pages are accessible', async ({ browser }) => { test('admin role: all dashboard pages are accessible', async ({ browser }, testInfo) => {
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!'); 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 { try {
for (const path of ALL_DASHBOARD_PAGES) { for (const path of ALL_DASHBOARD_PAGES) {
const page = await adminContext.newPage(); 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 }) => { 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 { try {
const page = await adminContext.newPage(); const page = await adminContext.newPage();
await page.goto('/'); await page.goto('/');

View File

@@ -58,65 +58,65 @@ describe('mtls-access-rules CRUD', () => {
it('createMtlsAccessRule creates a rule', async () => { it('createMtlsAccessRule creates a rule', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, proxyHostId: host.id,
path_pattern: '/admin/*', pathPattern: '/admin/*',
allowed_role_ids: [1, 2], allowedRoleIds: [1, 2],
allowed_cert_ids: [10], allowedCertIds: [10],
priority: 5, priority: 5,
description: 'admin only', description: 'admin only',
}, userId); }, userId);
expect(rule.proxy_host_id).toBe(host.id); expect(rule.proxyHostId).toBe(host.id);
expect(rule.path_pattern).toBe('/admin/*'); expect(rule.pathPattern).toBe('/admin/*');
expect(rule.allowed_role_ids).toEqual([1, 2]); expect(rule.allowedRoleIds).toEqual([1, 2]);
expect(rule.allowed_cert_ids).toEqual([10]); expect(rule.allowedCertIds).toEqual([10]);
expect(rule.priority).toBe(5); expect(rule.priority).toBe(5);
expect(rule.description).toBe('admin only'); 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 host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, proxyHostId: host.id,
path_pattern: ' /api/* ', pathPattern: ' /api/* ',
}, userId); }, userId);
expect(rule.path_pattern).toBe('/api/*'); expect(rule.pathPattern).toBe('/api/*');
}); });
it('createMtlsAccessRule defaults arrays to empty', async () => { it('createMtlsAccessRule defaults arrays to empty', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, proxyHostId: host.id,
path_pattern: '*', pathPattern: '*',
}, userId); }, userId);
expect(rule.allowed_role_ids).toEqual([]); expect(rule.allowedRoleIds).toEqual([]);
expect(rule.allowed_cert_ids).toEqual([]); expect(rule.allowedCertIds).toEqual([]);
expect(rule.deny_all).toBe(false); expect(rule.denyAll).toBe(false);
expect(rule.priority).toBe(0); expect(rule.priority).toBe(0);
}); });
it('createMtlsAccessRule with deny_all', async () => { it('createMtlsAccessRule with denyAll', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, proxyHostId: host.id,
path_pattern: '/blocked/*', pathPattern: '/blocked/*',
deny_all: true, denyAll: true,
}, userId); }, userId);
expect(rule.deny_all).toBe(true); expect(rule.denyAll).toBe(true);
}); });
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => { it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
const host = await insertHost(); const host = await insertHost();
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId); await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/b', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId); await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/a', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId); await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/c', priority: 1 }, userId);
const rules = await listMtlsAccessRules(host.id); const rules = await listMtlsAccessRules(host.id);
expect(rules).toHaveLength(3); expect(rules).toHaveLength(3);
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest) expect(rules[0].pathPattern).toBe('/a'); // priority 10 (highest)
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b expect(rules[1].pathPattern).toBe('/b'); // priority 1, path /b
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c expect(rules[2].pathPattern).toBe('/c'); // priority 1, path /c
}); });
it('listMtlsAccessRules returns empty array for host with no rules', async () => { 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 () => { it('listMtlsAccessRules only returns rules for the specified host', async () => {
const host1 = await insertHost('h1'); const host1 = await insertHost('h1');
const host2 = await insertHost('h2'); const host2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId); await createMtlsAccessRule({ proxyHostId: host1.id, pathPattern: '/h1' }, userId);
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId); await createMtlsAccessRule({ proxyHostId: host2.id, pathPattern: '/h2' }, userId);
const rules = await listMtlsAccessRules(host1.id); const rules = await listMtlsAccessRules(host1.id);
expect(rules).toHaveLength(1); expect(rules).toHaveLength(1);
expect(rules[0].path_pattern).toBe('/h1'); expect(rules[0].pathPattern).toBe('/h1');
}); });
it('getMtlsAccessRule returns a single rule', async () => { it('getMtlsAccessRule returns a single rule', async () => {
const host = await insertHost(); const host = await insertHost();
const created = await createMtlsAccessRule({ const created = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test', proxyHostId: host.id, pathPattern: '/test',
}, userId); }, userId);
const fetched = await getMtlsAccessRule(created.id); const fetched = await getMtlsAccessRule(created.id);
expect(fetched).not.toBeNull(); expect(fetched).not.toBeNull();
expect(fetched!.id).toBe(created.id); 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 () => { it('getMtlsAccessRule returns null for non-existent rule', async () => {
@@ -155,34 +155,34 @@ describe('mtls-access-rules CRUD', () => {
it('updateMtlsAccessRule updates fields', async () => { it('updateMtlsAccessRule updates fields', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/old', priority: 0, proxyHostId: host.id, pathPattern: '/old', priority: 0,
}, userId); }, userId);
const updated = await updateMtlsAccessRule(rule.id, { const updated = await updateMtlsAccessRule(rule.id, {
path_pattern: '/new', pathPattern: '/new',
priority: 99, priority: 99,
allowed_role_ids: [5], allowedRoleIds: [5],
deny_all: true, denyAll: true,
description: 'updated', description: 'updated',
}, userId); }, userId);
expect(updated.path_pattern).toBe('/new'); expect(updated.pathPattern).toBe('/new');
expect(updated.priority).toBe(99); expect(updated.priority).toBe(99);
expect(updated.allowed_role_ids).toEqual([5]); expect(updated.allowedRoleIds).toEqual([5]);
expect(updated.deny_all).toBe(true); expect(updated.denyAll).toBe(true);
expect(updated.description).toBe('updated'); expect(updated.description).toBe('updated');
}); });
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => { it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test', proxyHostId: host.id, pathPattern: '/test',
allowed_role_ids: [1], priority: 5, description: 'original', allowedRoleIds: [1], priority: 5, description: 'original',
}, userId); }, userId);
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId); const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
expect(updated.path_pattern).toBe('/test'); expect(updated.pathPattern).toBe('/test');
expect(updated.allowed_role_ids).toEqual([1]); expect(updated.allowedRoleIds).toEqual([1]);
expect(updated.description).toBe('original'); expect(updated.description).toBe('original');
expect(updated.priority).toBe(10); expect(updated.priority).toBe(10);
}); });
@@ -194,7 +194,7 @@ describe('mtls-access-rules CRUD', () => {
it('deleteMtlsAccessRule removes the rule', async () => { it('deleteMtlsAccessRule removes the rule', async () => {
const host = await insertHost(); const host = await insertHost();
const rule = await createMtlsAccessRule({ const rule = await createMtlsAccessRule({
proxy_host_id: host.id, path_pattern: '/test', proxyHostId: host.id, pathPattern: '/test',
}, userId); }, userId);
await deleteMtlsAccessRule(rule.id, 1); await deleteMtlsAccessRule(rule.id, 1);
@@ -221,9 +221,9 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('groups rules by proxy host ID', async () => { it('groups rules by proxy host ID', async () => {
const h1 = await insertHost('h1'); const h1 = await insertHost('h1');
const h2 = await insertHost('h2'); const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId); await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId); await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/b' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId); await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/c' }, userId);
const map = await getAccessRulesForHosts([h1.id, h2.id]); const map = await getAccessRulesForHosts([h1.id, h2.id]);
expect(map.get(h1.id)).toHaveLength(2); expect(map.get(h1.id)).toHaveLength(2);
@@ -233,8 +233,8 @@ describe('getAccessRulesForHosts (bulk query)', () => {
it('excludes hosts not in the query list', async () => { it('excludes hosts not in the query list', async () => {
const h1 = await insertHost('h1'); const h1 = await insertHost('h1');
const h2 = await insertHost('h2'); const h2 = await insertHost('h2');
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId); await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId); await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/b' }, userId);
const map = await getAccessRulesForHosts([h1.id]); const map = await getAccessRulesForHosts([h1.id]);
expect(map.has(h1.id)).toBe(true); 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 () => { it('rules within a host are ordered by priority desc, path asc', async () => {
const h = await insertHost(); const h = await insertHost();
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId); await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/z', priority: 10 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId); await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/a', priority: 1 }, userId);
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId); await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/m', priority: 10 }, userId);
const map = await getAccessRulesForHosts([h.id]); const map = await getAccessRulesForHosts([h.id]);
const rules = map.get(h.id)!; const rules = map.get(h.id)!;
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m expect(rules[0].pathPattern).toBe('/m'); // priority 10, path /m
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z expect(rules[1].pathPattern).toBe('/z'); // priority 10, path /z
expect(rules[2].path_pattern).toBe('/a'); // priority 1 expect(rules[2].pathPattern).toBe('/a'); // priority 1
}); });
}); });
describe('JSON parsing edge cases in access rules', () => { 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 host = await insertHost();
const now = nowIso(); const now = nowIso();
// Insert directly with bad JSON // Insert directly with bad JSON
@@ -267,7 +267,7 @@ describe('JSON parsing edge cases in access rules', () => {
}); });
const rules = await listMtlsAccessRules(host.id); 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 () => { 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); 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 () => { it('handles non-array JSON', async () => {
@@ -293,7 +293,7 @@ describe('JSON parsing edge cases in access rules', () => {
}); });
const rules = await listMtlsAccessRules(host.id); const rules = await listMtlsAccessRules(host.id);
expect(rules[0].allowed_role_ids).toEqual([]); expect(rules[0].allowedRoleIds).toEqual([]);
expect(rules[0].allowed_cert_ids).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); const role = await createMtlsRole({ name: 'admin', description: 'Admin role' }, userId);
expect(role.name).toBe('admin'); expect(role.name).toBe('admin');
expect(role.description).toBe('Admin role'); expect(role.description).toBe('Admin role');
expect(role.certificate_count).toBe(0); expect(role.certificateCount).toBe(0);
expect(role.id).toBeGreaterThan(0); expect(role.id).toBeGreaterThan(0);
}); });
@@ -115,7 +115,7 @@ describe('mtls-roles model CRUD', () => {
await assignRoleToCertificate(role.id, cert1.id, 1); await assignRoleToCertificate(role.id, cert1.id, 1);
const roles = await listMtlsRoles(); 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 () => { it('listMtlsRoles returns empty array when no roles', async () => {
@@ -123,7 +123,7 @@ describe('mtls-roles model CRUD', () => {
expect(roles).toEqual([]); expect(roles).toEqual([]);
}); });
it('getMtlsRole returns role with certificate_ids', async () => { it('getMtlsRole returns role with certificateIds', async () => {
const { cert1, cert2 } = await seedCaAndCerts(); const { cert1, cert2 } = await seedCaAndCerts();
const role = await createMtlsRole({ name: 'admin' }, userId); const role = await createMtlsRole({ name: 'admin' }, userId);
await assignRoleToCertificate(role.id, cert1.id, 1); await assignRoleToCertificate(role.id, cert1.id, 1);
@@ -131,9 +131,9 @@ describe('mtls-roles model CRUD', () => {
const fetched = await getMtlsRole(role.id); const fetched = await getMtlsRole(role.id);
expect(fetched).not.toBeNull(); expect(fetched).not.toBeNull();
expect(fetched!.certificate_ids).toHaveLength(2); expect(fetched!.certificateIds).toHaveLength(2);
expect(fetched!.certificate_ids).toContain(cert1.id); expect(fetched!.certificateIds).toContain(cert1.id);
expect(fetched!.certificate_ids).toContain(cert2.id); expect(fetched!.certificateIds).toContain(cert2.id);
}); });
it('getMtlsRole returns null for non-existent role', async () => { 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); await assignRoleToCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id); 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 () => { it('assignRoleToCertificate throws for non-existent role', async () => {
@@ -204,7 +204,7 @@ describe('mtls-roles certificate assignments', () => {
await removeRoleFromCertificate(role.id, cert1.id, 1); await removeRoleFromCertificate(role.id, cert1.id, 1);
const fetched = await getMtlsRole(role.id); const fetched = await getMtlsRole(role.id);
expect(fetched!.certificate_ids).toEqual([]); expect(fetched!.certificateIds).toEqual([]);
}); });
it('removeRoleFromCertificate throws for non-existent role', async () => { 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, cert1.id, 1);
await assignRoleToCertificate(role.id, cert2.id, 1); await assignRoleToCertificate(role.id, cert2.id, 1);
const fetched = await getMtlsRole(role.id); 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', () => { describe('authenticateApiRequest', () => {
it('authenticates via Bearer token', async () => { it('authenticates via Bearer token', async () => {
mockValidateToken.mockResolvedValue({ 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' }, user: { id: 42, role: 'admin' },
}); });
@@ -91,7 +91,7 @@ describe('authenticateApiRequest', () => {
describe('requireApiAdmin', () => { describe('requireApiAdmin', () => {
it('allows admin users', async () => { it('allows admin users', async () => {
mockValidateToken.mockResolvedValue({ 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' }, user: { id: 1, role: 'admin' },
}); });
@@ -101,7 +101,7 @@ describe('requireApiAdmin', () => {
it('rejects non-admin users with 403', async () => { it('rejects non-admin users with 403', async () => {
mockValidateToken.mockResolvedValue({ 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' }, user: { id: 2, role: 'user' },
}); });
@@ -154,7 +154,7 @@ describe('requireApiUser', () => {
it('CSRF check skips for Bearer-authenticated POST', async () => { it('CSRF check skips for Bearer-authenticated POST', async () => {
mockValidateToken.mockResolvedValue({ 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' }, user: { id: 42, role: 'admin' },
}); });

View File

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

View File

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

View File

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