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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
package-lock.json
|
||||
.next
|
||||
out
|
||||
dist
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { authClient } from "@/src/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -45,7 +45,7 @@ export default function LinkAccountClient({
|
||||
return;
|
||||
}
|
||||
|
||||
await signIn(provider, { callbackUrl: "/" });
|
||||
await authClient.signIn.social({ provider, callbackURL: "/" });
|
||||
} catch {
|
||||
setError("An error occurred while linking your account");
|
||||
setLoading(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { FormEvent, useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { authClient } from "@/src/lib/auth-client";
|
||||
import { LogIn } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -36,28 +36,24 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
redirect: false,
|
||||
callbackUrl: "/",
|
||||
const { data, error } = await authClient.signIn.username({
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
if (!result || result.error || result.ok === false) {
|
||||
if (error) {
|
||||
let message: string | null = null;
|
||||
if (result?.status === 429) {
|
||||
message = result.error && result.error !== "CredentialsSignin"
|
||||
? result.error
|
||||
: "Too many login attempts. Try again in a few minutes.";
|
||||
} else if (result?.error && result.error !== "CredentialsSignin") {
|
||||
message = result.error;
|
||||
if (error.status === 429) {
|
||||
message = error.message || "Too many login attempts. Try again in a few minutes.";
|
||||
} else if (error.message) {
|
||||
message = error.message;
|
||||
}
|
||||
setLoginError(message ?? "Invalid username or password.");
|
||||
setLoginPending(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.replace(result.url ?? "/");
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
@@ -65,7 +61,7 @@ export default function LoginClient({ enabledProviders = [] }: LoginClientProps)
|
||||
setLoginError(null);
|
||||
setOauthPending(providerId);
|
||||
try {
|
||||
await signIn(providerId, { callbackUrl: "/" });
|
||||
await authClient.signIn.social({ provider: providerId, callbackURL: "/" });
|
||||
} catch {
|
||||
setLoginError("Failed to sign in with OAuth");
|
||||
setOauthPending(null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/src/lib/auth";
|
||||
import { getEnabledOAuthProviders } from "@/src/lib/config";
|
||||
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
|
||||
import LoginClient from "./LoginClient";
|
||||
|
||||
export default async function LoginPage() {
|
||||
@@ -9,7 +9,7 @@ export default async function LoginPage() {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const enabledProviders = getEnabledOAuthProviders();
|
||||
const enabledProviders = await getProviderDisplayList();
|
||||
|
||||
return <LoginClient enabledProviders={enabledProviders} />;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { Shield } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { authClient } from "@/src/lib/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -97,7 +97,7 @@ export default function PortalLoginForm({
|
||||
// Redirect back to this portal page after OAuth, with the rid param preserved.
|
||||
// The rid is an opaque server-side ID — the actual redirect URI is never in the URL.
|
||||
const callbackUrl = `/portal?rid=${encodeURIComponent(rid)}`;
|
||||
signIn(providerId, { callbackUrl });
|
||||
authClient.signIn.social({ provider: providerId, callbackURL: callbackUrl });
|
||||
};
|
||||
|
||||
const disabled = pending || !!oauthPending;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { auth } from "@/src/lib/auth";
|
||||
import { getEnabledOAuthProviders } from "@/src/lib/config";
|
||||
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
|
||||
import { isForwardAuthDomain, createRedirectIntent } from "@/src/lib/models/forward-auth";
|
||||
import PortalLoginForm from "./PortalLoginForm";
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function PortalPage({ searchParams }: PortalPageProps) {
|
||||
}
|
||||
|
||||
const session = await auth();
|
||||
const enabledProviders = getEnabledOAuthProviders();
|
||||
const enabledProviders = await getProviderDisplayList();
|
||||
|
||||
return (
|
||||
<PortalLoginForm
|
||||
|
||||
@@ -14,7 +14,7 @@ type StatCard = {
|
||||
|
||||
type RecentEvent = {
|
||||
summary: string;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type TrafficSummary = {
|
||||
@@ -156,14 +156,14 @@ export default function OverviewClient({
|
||||
<div className="absolute left-[28px] top-4 bottom-4 w-px bg-border" />
|
||||
{recentEvents.map((event, index) => (
|
||||
<div
|
||||
key={`${event.created_at}-${index}`}
|
||||
key={`${event.createdAt}-${index}`}
|
||||
className="relative flex items-start gap-4 px-5 py-3 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
{/* Dot */}
|
||||
<div className={`relative z-10 mt-1 h-3 w-3 shrink-0 rounded-full ${getEventDotColor(event.summary)}`} />
|
||||
<span className="flex-1 text-sm leading-snug">{event.summary}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{formatRelativeTime(event.created_at)}
|
||||
{formatRelativeTime(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function AccessListsClient({ lists, pagination }: Props) {
|
||||
<div>
|
||||
<p className="text-sm font-medium font-mono leading-tight">{entry.username}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Added {new Date(entry.created_at).toLocaleDateString()}
|
||||
Added {new Date(entry.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { PageHeader } from "@/components/ui/PageHeader";
|
||||
|
||||
type EventRow = {
|
||||
id: number;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
user: string;
|
||||
summary: string;
|
||||
};
|
||||
@@ -61,7 +61,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
|
||||
width: 180,
|
||||
render: (r: EventRow) => (
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
{new Date(r.createdAt).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -88,7 +88,7 @@ export default function AuditLogClient({ events, pagination, initialSearch }: Pr
|
||||
<div className="flex justify-between items-center">
|
||||
<Badge variant="outline">{r.user}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(r.created_at).toLocaleString()}
|
||||
{new Date(r.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm">{r.summary}</p>
|
||||
|
||||
@@ -28,11 +28,11 @@ export default async function AuditLogPage({ searchParams }: PageProps) {
|
||||
<AuditLogClient
|
||||
events={events.map((event) => ({
|
||||
id: event.id,
|
||||
created_at: event.created_at,
|
||||
summary: event.summary ?? `${event.action} on ${event.entity_type}`,
|
||||
user: event.user_id
|
||||
? userMap.get(event.user_id)?.name ??
|
||||
userMap.get(event.user_id)?.email ??
|
||||
createdAt: event.createdAt,
|
||||
summary: event.summary ?? `${event.action} on ${event.entityType}`,
|
||||
user: event.userId
|
||||
? userMap.get(event.userId)?.name ??
|
||||
userMap.get(event.userId)?.email ??
|
||||
"System"
|
||||
: "System",
|
||||
}))}
|
||||
|
||||
@@ -23,10 +23,10 @@ export async function createCertificateAction(formData: FormData) {
|
||||
{
|
||||
name: String(formData.get("name") ?? "Certificate"),
|
||||
type,
|
||||
domain_names: parseDomains(formData.get("domain_names")),
|
||||
auto_renew: type === "managed" ? formData.get("auto_renew") === "on" : false,
|
||||
certificate_pem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
|
||||
private_key_pem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
|
||||
domainNames: parseDomains(formData.get("domain_names")),
|
||||
autoRenew: type === "managed" ? formData.get("auto_renew") === "on" : false,
|
||||
certificatePem: type === "imported" ? String(formData.get("certificate_pem") ?? "") : null,
|
||||
privateKeyPem: type === "imported" ? String(formData.get("private_key_pem") ?? "") : null
|
||||
},
|
||||
userId
|
||||
);
|
||||
@@ -42,10 +42,10 @@ export async function updateCertificateAction(id: number, formData: FormData) {
|
||||
{
|
||||
name: formData.get("name") ? String(formData.get("name")) : undefined,
|
||||
type,
|
||||
domain_names: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
|
||||
auto_renew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
|
||||
certificate_pem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
|
||||
private_key_pem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
|
||||
domainNames: formData.get("domain_names") ? parseDomains(formData.get("domain_names")) : undefined,
|
||||
autoRenew: formData.has("auto_renew_present") ? formData.get("auto_renew") === "on" : undefined,
|
||||
certificatePem: formData.get("certificate_pem") ? String(formData.get("certificate_pem")) : undefined,
|
||||
privateKeyPem: formData.get("private_key_pem") ? String(formData.get("private_key_pem")) : undefined
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function createCaCertificateAction(formData: FormData) {
|
||||
if (!certificatePem) throw new Error("Certificate PEM is required");
|
||||
validatePem(certificatePem);
|
||||
|
||||
await createCaCertificate({ name, certificate_pem: certificatePem }, userId);
|
||||
await createCaCertificate({ name, certificatePem: certificatePem }, userId);
|
||||
revalidatePath("/certificates");
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function updateCaCertificateAction(id: number, formData: FormData)
|
||||
|
||||
await updateCaCertificate(id, {
|
||||
...(name ? { name } : {}),
|
||||
...(certificatePem ? { certificate_pem: certificatePem } : {})
|
||||
...(certificatePem ? { certificatePem: certificatePem } : {})
|
||||
}, userId);
|
||||
revalidatePath("/certificates");
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export async function generateCaCertificateAction(formData: FormData): Promise<{
|
||||
const certificatePem = forge.pki.certificateToPem(cert);
|
||||
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
|
||||
|
||||
const record = await createCaCertificate({ name, certificate_pem: certificatePem, private_key_pem: privateKeyPem }, userId);
|
||||
const record = await createCaCertificate({ name, certificatePem: certificatePem, privateKeyPem: privateKeyPem }, userId);
|
||||
revalidatePath("/certificates");
|
||||
return { id: record.id };
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export async function issueClientCertificateAction(
|
||||
if (!caCertRecord) throw new Error("CA certificate not found");
|
||||
|
||||
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
|
||||
const caCert = forge.pki.certificateFromPem(caCertRecord.certificate_pem);
|
||||
const caCert = forge.pki.certificateFromPem(caCertRecord.certificatePem);
|
||||
|
||||
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
|
||||
const cert = forge.pki.createCertificate();
|
||||
@@ -149,13 +149,13 @@ export async function issueClientCertificateAction(
|
||||
|
||||
await createIssuedClientCertificate(
|
||||
{
|
||||
ca_certificate_id: caCertId,
|
||||
common_name: commonName,
|
||||
serial_number: cert.serialNumber.toUpperCase(),
|
||||
fingerprint_sha256: certificate.fingerprint256,
|
||||
certificate_pem: certificatePem,
|
||||
valid_from: new Date(certificate.validFrom).toISOString(),
|
||||
valid_to: new Date(certificate.validTo).toISOString()
|
||||
caCertificateId: caCertId,
|
||||
commonName: commonName,
|
||||
serialNumber: cert.serialNumber.toUpperCase(),
|
||||
fingerprintSha256: certificate.fingerprint256,
|
||||
certificatePem: certificatePem,
|
||||
validFrom: new Date(certificate.validFrom).toISOString(),
|
||||
validTo: new Date(certificate.validTo).toISOString()
|
||||
},
|
||||
userId
|
||||
);
|
||||
@@ -184,5 +184,5 @@ export async function revokeIssuedClientCertificateAction(id: number): Promise<{
|
||||
const userId = Number(session.user.id);
|
||||
const record = await revokeIssuedClientCertificate(id, userId);
|
||||
revalidatePath("/certificates");
|
||||
return { revokedAt: record.revoked_at! };
|
||||
return { revokedAt: record.revokedAt! };
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function CaCertDrawer({ open, cert, onClose }: Props) {
|
||||
id="edit-cert-pem"
|
||||
name="certificate_pem"
|
||||
required
|
||||
defaultValue={cert.certificate_pem}
|
||||
defaultValue={cert.certificatePem}
|
||||
rows={8}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
|
||||
@@ -49,7 +49,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
|
||||
const [issueCaOpen, setIssueCaOpen] = useState(false);
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
|
||||
const active = ca.issuedCerts.filter((c) => !c.revoked_at);
|
||||
const active = ca.issuedCerts.filter((c) => !c.revokedAt);
|
||||
|
||||
return (
|
||||
<div className="px-5 py-4 bg-muted/30 border-t">
|
||||
@@ -62,7 +62,7 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{ca.has_private_key && (
|
||||
{ca.hasPrivateKey && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={() => setIssueCaOpen(true)}>
|
||||
Issue Cert
|
||||
</Button>
|
||||
@@ -80,10 +80,10 @@ function IssuedCertsPanel({ ca }: { ca: CaCertificateView }) {
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
|
||||
{active.slice(0, 5).map((issued) => {
|
||||
const expired = new Date(issued.valid_to).getTime() < Date.now();
|
||||
const expired = new Date(issued.validTo).getTime() < Date.now();
|
||||
return (
|
||||
<div key={issued.id} className="flex items-center justify-between gap-2 px-3 py-2 bg-background/60">
|
||||
<span className="text-sm font-mono">{issued.common_name}</span>
|
||||
<span className="text-sm font-mono">{issued.commonName}</span>
|
||||
<Badge variant={expired ? "destructive" : "success"} className="text-[10px] px-1.5 py-0">
|
||||
{expired ? "Expired" : "Active"}
|
||||
</Badge>
|
||||
@@ -135,7 +135,7 @@ function CaActionsMenu({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{ca.has_private_key && (
|
||||
{ca.hasPrivateKey && (
|
||||
<DropdownMenuItem onClick={() => { setOpen(false); setIssuedOpen(true); }}>
|
||||
Issue Client Cert
|
||||
</DropdownMenuItem>
|
||||
@@ -186,7 +186,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
</Card>
|
||||
) : (
|
||||
filtered.map((ca) => {
|
||||
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
|
||||
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
|
||||
return (
|
||||
<Card key={ca.id} className="border-l-2 border-l-violet-500">
|
||||
<CardContent className="p-4 flex flex-col gap-2">
|
||||
@@ -200,7 +200,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
<CaActionsMenu ca={ca} onEdit={() => setDrawerCert(ca)} onDelete={() => setDeleteCert(ca)} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ca.has_private_key && (
|
||||
{ca.hasPrivateKey && (
|
||||
<Badge variant="success" className="text-[10px] px-1.5 py-0">
|
||||
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Key stored
|
||||
</Badge>
|
||||
@@ -210,7 +210,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
{activeCount}/{ca.issuedCerts.length} active
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -241,7 +241,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
</TableRow>
|
||||
) : (
|
||||
filtered.map((ca) => {
|
||||
const activeCount = ca.issuedCerts.filter((c) => !c.revoked_at).length;
|
||||
const activeCount = ca.issuedCerts.filter((c) => !c.revokedAt).length;
|
||||
const expanded = expandedId === ca.id;
|
||||
return (
|
||||
<React.Fragment key={ca.id}>
|
||||
@@ -267,7 +267,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{ca.has_private_key ? (
|
||||
{ca.hasPrivateKey ? (
|
||||
<Badge variant="success" className="text-[10px] px-1.5 py-0">
|
||||
<KeyRound className="h-2.5 w-2.5 mr-0.5" />Stored
|
||||
</Badge>
|
||||
@@ -285,7 +285,7 @@ export function CaTab({ caCertificates, search, statusFilter }: Props) {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.created_at)}</span>
|
||||
<span className="text-sm text-muted-foreground">{formatRelativeDate(ca.createdAt)}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<CaActionsMenu
|
||||
|
||||
@@ -254,7 +254,7 @@ function LegacyManagedTable({ managedCerts }: { managedCerts: ManagedCertView[]
|
||||
id: "domains",
|
||||
label: "Domains",
|
||||
render: (c: ManagedCertView) => (
|
||||
<p className="text-sm text-muted-foreground font-mono">{c.domain_names.join(", ")}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{c.domainNames.join(", ")}</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ export type AcmeHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
domains: string[];
|
||||
ssl_forced: boolean;
|
||||
sslForced: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export type ImportedCertView = {
|
||||
usedBy: { id: number; name: string; domains: string[] }[];
|
||||
};
|
||||
|
||||
export type ManagedCertView = { id: number; name: string; domain_names: string[] };
|
||||
export type ManagedCertView = { id: number; name: string; domainNames: string[] };
|
||||
|
||||
const PER_PAGE = 25;
|
||||
|
||||
@@ -124,7 +124,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
domains: JSON.parse(r.domains) as string[],
|
||||
ssl_forced: r.sslForced,
|
||||
sslForced: r.sslForced,
|
||||
enabled: r.enabled,
|
||||
}));
|
||||
|
||||
@@ -143,9 +143,9 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
const importedCerts: ImportedCertView[] = [];
|
||||
const managedCerts: ManagedCertView[] = [];
|
||||
const issuedByCa = issuedClientCerts.reduce<Map<number, IssuedClientCertificate[]>>((map, cert) => {
|
||||
const current = map.get(cert.ca_certificate_id) ?? [];
|
||||
const current = map.get(cert.caCertificateId) ?? [];
|
||||
current.push(cert);
|
||||
map.set(cert.ca_certificate_id, current);
|
||||
map.set(cert.caCertificateId, current);
|
||||
return map;
|
||||
}, new Map());
|
||||
const caCertificateViews: CaCertificateView[] = caCerts.map((cert) => ({
|
||||
@@ -168,7 +168,7 @@ export default async function CertificatesPage({ searchParams }: PageProps) {
|
||||
usedBy: usageMap.get(cert.id) ?? [],
|
||||
});
|
||||
} else {
|
||||
managedCerts.push({ id: cert.id, name: cert.name, domain_names: domainNames });
|
||||
managedCerts.push({ id: cert.id, name: cert.name, domainNames: domainNames });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ import {
|
||||
} from "./actions";
|
||||
|
||||
type GroupMember = {
|
||||
user_id: number;
|
||||
userId: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type Group = {
|
||||
@@ -28,8 +28,8 @@ type Group = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: GroupMember[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type UserEntry = {
|
||||
@@ -50,7 +50,7 @@ export default function GroupsClient({ groups, users }: Props) {
|
||||
const [addMemberGroupId, setAddMemberGroupId] = useState<number | null>(null);
|
||||
|
||||
function getAvailableUsers(group: Group): UserEntry[] {
|
||||
const memberIds = new Set(group.members.map((m) => m.user_id));
|
||||
const memberIds = new Set(group.members.map((m) => m.userId));
|
||||
return users.filter((u) => !memberIds.has(u.id));
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ export default function GroupsClient({ groups, users }: Props) {
|
||||
<div className="space-y-1">
|
||||
{group.members.map((member) => (
|
||||
<div
|
||||
key={member.user_id}
|
||||
key={member.userId}
|
||||
className="flex items-center justify-between py-1 px-2 rounded hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -223,7 +223,7 @@ export default function GroupsClient({ groups, users }: Props) {
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={async () => {
|
||||
await removeGroupMemberAction(group.id, member.user_id);
|
||||
await removeGroupMemberAction(group.id, member.userId);
|
||||
router.refresh();
|
||||
}}
|
||||
title="Remove member"
|
||||
|
||||
@@ -31,9 +31,9 @@ type Props = {
|
||||
};
|
||||
|
||||
function formatMatcher(host: L4ProxyHost): string {
|
||||
switch (host.matcher_type) {
|
||||
case "tls_sni": return `SNI: ${host.matcher_value.join(", ")}`;
|
||||
case "http_host": return `Host: ${host.matcher_value.join(", ")}`;
|
||||
switch (host.matcherType) {
|
||||
case "tls_sni": return `SNI: ${host.matcherValue.join(", ")}`;
|
||||
case "http_host": return `Host: ${host.matcherValue.join(", ")}`;
|
||||
case "proxy_protocol": return "Proxy Protocol";
|
||||
default: return "None";
|
||||
}
|
||||
@@ -111,10 +111,10 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
|
||||
{
|
||||
id: "listen",
|
||||
label: "Listen",
|
||||
sortKey: "listen_address",
|
||||
sortKey: "listenAddress",
|
||||
render: (host: L4ProxyHost) => (
|
||||
<span className="text-sm font-mono font-medium tabular-nums text-foreground/80">
|
||||
{host.listen_address}
|
||||
{host.listenAddress}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -190,7 +190,7 @@ export default function L4ProxyHostsClient({ hosts, pagination, initialSearch, i
|
||||
<ProtocolBadge protocol={host.protocol} />
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{host.listen_address}
|
||||
{host.listenAddress}
|
||||
<span className="mx-1 text-muted-foreground">→</span>
|
||||
{host.upstreams[0]}{host.upstreams.length > 1 ? ` +${host.upstreams.length - 1}` : ""}
|
||||
</p>
|
||||
|
||||
@@ -178,17 +178,17 @@ export async function createL4ProxyHostAction(
|
||||
const input: L4ProxyHostInput = {
|
||||
name: String(formData.get("name") ?? "Untitled"),
|
||||
protocol: parseProtocol(formData),
|
||||
listen_address: String(formData.get("listen_address") ?? "").trim(),
|
||||
listenAddress: String(formData.get("listen_address") ?? "").trim(),
|
||||
upstreams: parseUpstreams(formData.get("upstreams")),
|
||||
matcher_type: matcherType,
|
||||
matcher_value: matcherValue,
|
||||
tls_termination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxy_protocol_version: parseProxyProtocolVersion(formData),
|
||||
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
matcherType: matcherType,
|
||||
matcherValue: matcherValue,
|
||||
tlsTermination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxyProtocolVersion: parseProxyProtocolVersion(formData),
|
||||
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
enabled: parseCheckbox(formData.get("enabled")),
|
||||
load_balancer: parseL4LoadBalancerConfig(formData),
|
||||
dns_resolver: parseL4DnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
loadBalancer: parseL4LoadBalancerConfig(formData),
|
||||
dnsResolver: parseL4DnsResolverConfig(formData),
|
||||
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
...parseL4GeoBlockConfig(formData),
|
||||
};
|
||||
|
||||
@@ -219,17 +219,17 @@ export async function updateL4ProxyHostAction(
|
||||
const input: Partial<L4ProxyHostInput> = {
|
||||
name: formData.get("name") ? String(formData.get("name")) : undefined,
|
||||
protocol: parseProtocol(formData),
|
||||
listen_address: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
|
||||
listenAddress: formData.get("listen_address") ? String(formData.get("listen_address")).trim() : undefined,
|
||||
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
|
||||
matcher_type: matcherType,
|
||||
matcher_value: matcherValue,
|
||||
tls_termination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxy_protocol_version: parseProxyProtocolVersion(formData),
|
||||
proxy_protocol_receive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
matcherType: matcherType,
|
||||
matcherValue: matcherValue,
|
||||
tlsTermination: parseCheckbox(formData.get("tls_termination")),
|
||||
proxyProtocolVersion: parseProxyProtocolVersion(formData),
|
||||
proxyProtocolReceive: parseCheckbox(formData.get("proxy_protocol_receive")),
|
||||
enabled: formData.has("enabled_present") ? parseCheckbox(formData.get("enabled")) : undefined,
|
||||
load_balancer: parseL4LoadBalancerConfig(formData),
|
||||
dns_resolver: parseL4DnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
loadBalancer: parseL4LoadBalancerConfig(formData),
|
||||
dnsResolver: parseL4DnsResolverConfig(formData),
|
||||
upstreamDnsResolution: parseL4UpstreamDnsResolutionConfig(formData),
|
||||
...parseL4GeoBlockConfig(formData),
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function L4ProxyHostsPage({ searchParams }: PageProps) {
|
||||
hosts={hosts}
|
||||
pagination={{ total, page, perPage: PER_PAGE }}
|
||||
initialSearch={search ?? ""}
|
||||
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
|
||||
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function OverviewPage() {
|
||||
isAdmin={true}
|
||||
recentEvents={recentEventsRaw.map((event) => ({
|
||||
summary: event.summary ?? `${event.action} on ${event.entityType}`,
|
||||
created_at: toIso(event.createdAt)!
|
||||
createdAt: toIso(event.createdAt)!
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { authClient } from "@/src/lib/auth-client";
|
||||
import { Camera, Check, Clock, Copy, Key, Link, LogIn, Lock, Plus, Trash2, Unlink, User, AlertTriangle } from "lucide-react";
|
||||
import type { ApiToken } from "@/lib/models/api-tokens";
|
||||
import { createApiTokenAction, deleteApiTokenAction } from "../api-tokens/actions";
|
||||
@@ -26,11 +26,11 @@ interface UserData {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
provider: string;
|
||||
subject: string;
|
||||
password_hash: string | null;
|
||||
provider: string | null;
|
||||
subject: string | null;
|
||||
passwordHash: string | null;
|
||||
role: string;
|
||||
avatar_url: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
interface ProfileClientProps {
|
||||
@@ -48,11 +48,11 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatar_url);
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | null>(user.avatarUrl);
|
||||
const [newToken, setNewToken] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const hasPassword = !!user.password_hash;
|
||||
const hasPassword = !!user.passwordHash;
|
||||
const hasOAuth = user.provider !== "credentials";
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
@@ -158,9 +158,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
}
|
||||
|
||||
// Now initiate OAuth flow
|
||||
await signIn(providerId, {
|
||||
callbackUrl: "/profile"
|
||||
});
|
||||
await authClient.signIn.social({ provider: providerId, callbackURL: "/profile" });
|
||||
} catch {
|
||||
setError("An error occurred while linking OAuth");
|
||||
setLoading(false);
|
||||
@@ -382,7 +380,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Authentication Method</p>
|
||||
<Badge variant={user.provider === "credentials" ? "secondary" : "default"}>
|
||||
{getProviderName(user.provider)}
|
||||
{getProviderName(user.provider ?? "")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -442,7 +440,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
{hasOAuth ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Your account is linked to {getProviderName(user.provider)}
|
||||
Your account is linked to {getProviderName(user.provider ?? "")}
|
||||
</p>
|
||||
|
||||
{hasPassword ? (
|
||||
@@ -523,7 +521,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
{apiTokens.length > 0 && (
|
||||
<div className="flex flex-col divide-y divide-border rounded-md border overflow-hidden">
|
||||
{apiTokens.map((token) => {
|
||||
const expired = isExpired(token.expires_at);
|
||||
const expired = isExpired(token.expiresAt);
|
||||
return (
|
||||
<div
|
||||
key={token.id}
|
||||
@@ -543,15 +541,15 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatDate(token.created_at)}
|
||||
Created {formatDate(token.createdAt)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
Used {formatDate(token.last_used_at)}
|
||||
Used {formatDate(token.lastUsedAt)}
|
||||
</p>
|
||||
{token.expires_at && (
|
||||
{token.expiresAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{expired ? "Expired" : "Expires"} {formatDate(token.expires_at)}
|
||||
{expired ? "Expired" : "Expires"} {formatDate(token.expiresAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -656,7 +654,7 @@ export default function ProfileClient({ user, enabledProviders, apiTokens }: Pro
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unlink OAuth Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to unlink your {getProviderName(user.provider)} account?
|
||||
Are you sure you want to unlink your {getProviderName(user.provider ?? "")} account?
|
||||
You will only be able to sign in with your username and password after this.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { requireUser } from "@/src/lib/auth";
|
||||
import { getUserById } from "@/src/lib/models/user";
|
||||
import { getEnabledOAuthProviders } from "@/src/lib/config";
|
||||
import { getProviderDisplayList } from "@/src/lib/models/oauth-providers";
|
||||
import { listApiTokens } from "@/src/lib/models/api-tokens";
|
||||
import ProfileClient from "./ProfileClient";
|
||||
import { redirect } from "next/navigation";
|
||||
@@ -15,7 +15,7 @@ export default async function ProfilePage() {
|
||||
}
|
||||
|
||||
const [enabledProviders, apiTokens] = await Promise.all([
|
||||
Promise.resolve(getEnabledOAuthProviders()),
|
||||
getProviderDisplayList(),
|
||||
listApiTokens(userId),
|
||||
]);
|
||||
|
||||
|
||||
@@ -133,10 +133,10 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
label: "Features",
|
||||
render: (host: ProxyHost) => {
|
||||
const badges = [
|
||||
host.certificate_id && (
|
||||
host.certificateId && (
|
||||
<Badge key="tls" variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>
|
||||
),
|
||||
host.access_list_id && (
|
||||
host.accessListId && (
|
||||
<Badge key="auth" variant="warning" className="text-[10px] px-1.5 py-0">
|
||||
<Shield className="h-2.5 w-2.5 mr-0.5" />Auth
|
||||
</Badge>
|
||||
@@ -156,7 +156,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
<MapPin className="h-2.5 w-2.5 mr-0.5" />Geo
|
||||
</Badge>
|
||||
),
|
||||
host.load_balancer?.enabled && (
|
||||
host.loadBalancer?.enabled && (
|
||||
<Badge key="lb" variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
<Scale className="h-2.5 w-2.5 mr-0.5" />LB
|
||||
</Badge>
|
||||
@@ -244,7 +244,7 @@ export default function ProxyHostsClient({ hosts, certificates, accessLists, caC
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
<StatusChip status={host.enabled ? "active" : "inactive"} />
|
||||
{host.certificate_id && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
|
||||
{host.certificateId && <Badge variant="info" className="text-[10px] px-1.5 py-0">TLS</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
|
||||
@@ -497,7 +497,7 @@ export async function createProxyHostAction(
|
||||
const session = await requireAdmin();
|
||||
const userId = Number(session.user.id);
|
||||
|
||||
// Parse certificate_id safely
|
||||
// Parse certificateId safely
|
||||
const parsedCertificateId = parseCertificateId(formData.get("certificate_id"));
|
||||
|
||||
// Validate certificate exists and get sanitized value
|
||||
@@ -516,25 +516,25 @@ export async function createProxyHostAction(
|
||||
name: String(formData.get("name") ?? "Untitled"),
|
||||
domains: parseCsv(formData.get("domains")),
|
||||
upstreams: parseUpstreams(formData.get("upstreams")),
|
||||
certificate_id: certificateId,
|
||||
access_list_id: parseAccessListId(formData.get("access_list_id")),
|
||||
ssl_forced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
|
||||
hsts_subdomains: parseCheckbox(formData.get("hsts_subdomains")),
|
||||
skip_https_hostname_validation: parseCheckbox(formData.get("skip_https_hostname_validation")),
|
||||
certificateId: certificateId,
|
||||
accessListId: parseAccessListId(formData.get("access_list_id")),
|
||||
sslForced: formData.has("ssl_forced_present") ? parseCheckbox(formData.get("ssl_forced")) : undefined,
|
||||
hstsSubdomains: parseCheckbox(formData.get("hsts_subdomains")),
|
||||
skipHttpsHostnameValidation: parseCheckbox(formData.get("skip_https_hostname_validation")),
|
||||
enabled: parseCheckbox(formData.get("enabled")),
|
||||
custom_pre_handlers_json: parseOptionalText(formData.get("custom_pre_handlers_json")),
|
||||
custom_reverse_proxy_json: parseOptionalText(formData.get("custom_reverse_proxy_json")),
|
||||
customPreHandlersJson: parseOptionalText(formData.get("custom_pre_handlers_json")),
|
||||
customReverseProxyJson: parseOptionalText(formData.get("custom_reverse_proxy_json")),
|
||||
authentik: parseAuthentikConfig(formData),
|
||||
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
|
||||
loadBalancer: parseLoadBalancerConfig(formData),
|
||||
dnsResolver: parseDnsResolverConfig(formData),
|
||||
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
...parseGeoBlockConfig(formData),
|
||||
...parseWafConfig(formData),
|
||||
mtls: parseMtlsConfig(formData),
|
||||
redirects: parseRedirectsConfig(formData),
|
||||
rewrite: parseRewriteConfig(formData),
|
||||
location_rules: parseLocationRulesConfig(formData),
|
||||
locationRules: parseLocationRulesConfig(formData),
|
||||
},
|
||||
userId
|
||||
);
|
||||
@@ -542,7 +542,7 @@ export async function createProxyHostAction(
|
||||
// Save forward auth access if CPM forward auth is enabled
|
||||
const faUserIds = formData.getAll("cpm_fa_user_id").map((v) => Number(v)).filter((n) => n > 0);
|
||||
const faGroupIds = formData.getAll("cpm_fa_group_id").map((v) => Number(v)).filter((n) => n > 0);
|
||||
if (host.cpm_forward_auth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
|
||||
if (host.cpmForwardAuth?.enabled && (faUserIds.length > 0 || faGroupIds.length > 0)) {
|
||||
await setForwardAuthAccess(host.id, { userIds: faUserIds, groupIds: faGroupIds }, userId);
|
||||
}
|
||||
|
||||
@@ -597,30 +597,30 @@ export async function updateProxyHostAction(
|
||||
name: formData.get("name") ? String(formData.get("name")) : undefined,
|
||||
domains: formData.get("domains") ? parseCsv(formData.get("domains")) : undefined,
|
||||
upstreams: formData.get("upstreams") ? parseUpstreams(formData.get("upstreams")) : undefined,
|
||||
certificate_id: certificateId,
|
||||
access_list_id: formData.has("access_list_id")
|
||||
certificateId: certificateId,
|
||||
accessListId: formData.has("access_list_id")
|
||||
? parseAccessListId(formData.get("access_list_id"))
|
||||
: undefined,
|
||||
hsts_subdomains: boolField("hsts_subdomains"),
|
||||
skip_https_hostname_validation: boolField("skip_https_hostname_validation"),
|
||||
hstsSubdomains: boolField("hsts_subdomains"),
|
||||
skipHttpsHostnameValidation: boolField("skip_https_hostname_validation"),
|
||||
enabled: boolField("enabled"),
|
||||
custom_pre_handlers_json: formData.has("custom_pre_handlers_json")
|
||||
customPreHandlersJson: formData.has("custom_pre_handlers_json")
|
||||
? parseOptionalText(formData.get("custom_pre_handlers_json"))
|
||||
: undefined,
|
||||
custom_reverse_proxy_json: formData.has("custom_reverse_proxy_json")
|
||||
customReverseProxyJson: formData.has("custom_reverse_proxy_json")
|
||||
? parseOptionalText(formData.get("custom_reverse_proxy_json"))
|
||||
: undefined,
|
||||
authentik: parseAuthentikConfig(formData),
|
||||
cpm_forward_auth: parseCpmForwardAuthConfig(formData),
|
||||
load_balancer: parseLoadBalancerConfig(formData),
|
||||
dns_resolver: parseDnsResolverConfig(formData),
|
||||
upstream_dns_resolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
cpmForwardAuth: parseCpmForwardAuthConfig(formData),
|
||||
loadBalancer: parseLoadBalancerConfig(formData),
|
||||
dnsResolver: parseDnsResolverConfig(formData),
|
||||
upstreamDnsResolution: parseUpstreamDnsResolutionConfig(formData),
|
||||
...parseGeoBlockConfig(formData),
|
||||
...parseWafConfig(formData),
|
||||
mtls: formData.has("mtls_present") ? parseMtlsConfig(formData) : undefined,
|
||||
redirects: formData.has("redirects_json") ? parseRedirectsConfig(formData) : undefined,
|
||||
rewrite: formData.has("rewrite_path_prefix") ? parseRewriteConfig(formData) : undefined,
|
||||
location_rules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
|
||||
locationRules: formData.has("location_rules_json") ? parseLocationRulesConfig(formData) : undefined,
|
||||
},
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
|
||||
]);
|
||||
|
||||
// Build forward auth access map for hosts that have CPM forward auth enabled
|
||||
const faHosts = hosts.filter((h) => h.cpm_forward_auth?.enabled);
|
||||
const faHosts = hosts.filter((h) => h.cpmForwardAuth?.enabled);
|
||||
const faAccessEntries = await Promise.all(
|
||||
faHosts.map((h) => getForwardAuthAccessForHost(h.id).catch(() => []))
|
||||
);
|
||||
@@ -51,8 +51,8 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
|
||||
faHosts.forEach((h, i) => {
|
||||
const entries = faAccessEntries[i];
|
||||
forwardAuthAccessMap[h.id] = {
|
||||
userIds: entries.filter((e) => e.user_id !== null).map((e) => e.user_id!),
|
||||
groupIds: entries.filter((e) => e.group_id !== null).map((e) => e.group_id!),
|
||||
userIds: entries.filter((e) => e.userId !== null).map((e) => e.userId!),
|
||||
groupIds: entries.filter((e) => e.groupId !== null).map((e) => e.groupId!),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -78,7 +78,7 @@ export default async function ProxyHostsPage({ searchParams }: PageProps) {
|
||||
authentikDefaults={authentikDefaults}
|
||||
pagination={{ total, page, perPage: PER_PAGE }}
|
||||
initialSearch={search ?? ""}
|
||||
initialSort={{ sortBy: sortBy ?? "created_at", sortDir }}
|
||||
initialSort={{ sortBy: sortBy ?? "createdAt", sortDir }}
|
||||
mtlsRoles={mtlsRoles}
|
||||
issuedClientCerts={issuedClientCerts}
|
||||
forwardAuthUsers={forwardAuthUsers}
|
||||
|
||||
464
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal file
464
app/(dashboard)/settings/OAuthProvidersSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useFormState } from "react-dom";
|
||||
import {
|
||||
Cloud, Globe, Network, Pin, Activity,
|
||||
ScrollText, Settings2, UserCheck, MapPin,
|
||||
ScrollText, Settings2, UserCheck, MapPin, KeyRound,
|
||||
} from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -24,6 +24,8 @@ import type {
|
||||
GeoBlockSettings,
|
||||
} from "@/lib/settings";
|
||||
import { GeoBlockFields } from "@/components/proxy-hosts/GeoBlockFields";
|
||||
import OAuthProvidersSection from "./OAuthProvidersSection";
|
||||
import type { OAuthProvider } from "@/src/lib/models/oauth-providers";
|
||||
import {
|
||||
updateCloudflareSettingsAction,
|
||||
updateGeneralSettingsAction,
|
||||
@@ -117,6 +119,7 @@ const A: Record<string, AccentConfig> = {
|
||||
metrics: { border: "border-l-rose-500", icon: "border-rose-500/30 bg-rose-500/10 text-rose-500" },
|
||||
logging: { border: "border-l-amber-500", icon: "border-amber-500/30 bg-amber-500/10 text-amber-500" },
|
||||
geoblock: { border: "border-l-teal-500", icon: "border-teal-500/30 bg-teal-500/10 text-teal-500" },
|
||||
oauth: { border: "border-l-indigo-500", icon: "border-indigo-500/30 bg-indigo-500/10 text-indigo-500" },
|
||||
};
|
||||
|
||||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||
@@ -134,6 +137,8 @@ type Props = {
|
||||
dns: DnsSettings | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionSettings | null;
|
||||
globalGeoBlock?: GeoBlockSettings | null;
|
||||
oauthProviders: OAuthProvider[];
|
||||
baseUrl: string;
|
||||
instanceSync: {
|
||||
mode: "standalone" | "master" | "slave";
|
||||
modeFromEnv: boolean;
|
||||
@@ -156,10 +161,10 @@ type Props = {
|
||||
instances: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
}>;
|
||||
envInstances: Array<{
|
||||
name: string;
|
||||
@@ -180,6 +185,8 @@ export default function SettingsClient({
|
||||
dns,
|
||||
upstreamDnsResolution,
|
||||
globalGeoBlock,
|
||||
oauthProviders,
|
||||
baseUrl,
|
||||
instanceSync
|
||||
}: Props) {
|
||||
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
|
||||
@@ -376,12 +383,12 @@ export default function SettingsClient({
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{instance.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{instance.base_url}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{instance.baseUrl}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
|
||||
{instance.lastSyncAt ? `Last sync: ${instance.lastSyncAt}` : "No sync yet"}
|
||||
</span>
|
||||
{instance.last_sync_error && (
|
||||
<span className="block text-xs text-destructive">{instance.last_sync_error}</span>
|
||||
{instance.lastSyncError && (
|
||||
<span className="block text-xs text-destructive">{instance.lastSyncError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -853,6 +860,16 @@ export default function SettingsClient({
|
||||
</div>
|
||||
</form>
|
||||
</SettingSection>
|
||||
|
||||
{/* ── OAuth Providers ── */}
|
||||
<SettingSection
|
||||
icon={<KeyRound className="h-4 w-4" />}
|
||||
title="OAuth Providers"
|
||||
description="Configure OAuth/OIDC providers for single sign-on. Users can log in via these providers in addition to local credentials."
|
||||
accent={A.oauth}
|
||||
>
|
||||
<OAuthProvidersSection initialProviders={oauthProviders} baseUrl={baseUrl} />
|
||||
</SettingSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -676,6 +676,76 @@ export async function suppressWafRuleGloballyAction(ruleId: number): Promise<Act
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOAuthProvidersAction() {
|
||||
await requireAdmin();
|
||||
const { listOAuthProviders } = await import("@/src/lib/models/oauth-providers");
|
||||
return listOAuthProviders();
|
||||
}
|
||||
|
||||
export async function createOAuthProviderAction(data: {
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer?: string;
|
||||
authorizationUrl?: string;
|
||||
tokenUrl?: string;
|
||||
userinfoUrl?: string;
|
||||
scopes?: string;
|
||||
autoLink?: boolean;
|
||||
}) {
|
||||
const session = await requireAdmin();
|
||||
const { createOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
const provider = await createOAuthProvider({ ...data, source: "ui" });
|
||||
invalidateProviderCache();
|
||||
const { createAuditEvent } = await import("@/src/lib/models/audit");
|
||||
await createAuditEvent({
|
||||
userId: Number(session.user.id),
|
||||
action: "oauth_provider_created",
|
||||
entityType: "oauth_provider",
|
||||
entityId: null,
|
||||
summary: `OAuth provider "${data.name}" created`,
|
||||
data: JSON.stringify({ providerId: provider.id }),
|
||||
});
|
||||
revalidatePath("/settings");
|
||||
return provider;
|
||||
}
|
||||
|
||||
export async function updateOAuthProviderAction(
|
||||
id: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
type: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
issuer: string | null;
|
||||
authorizationUrl: string | null;
|
||||
tokenUrl: string | null;
|
||||
userinfoUrl: string | null;
|
||||
scopes: string;
|
||||
autoLink: boolean;
|
||||
enabled: boolean;
|
||||
}>
|
||||
) {
|
||||
await requireAdmin();
|
||||
const { updateOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
const updated = await updateOAuthProvider(id, data);
|
||||
invalidateProviderCache();
|
||||
revalidatePath("/settings");
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteOAuthProviderAction(id: string) {
|
||||
await requireAdmin();
|
||||
const { deleteOAuthProvider } = await import("@/src/lib/models/oauth-providers");
|
||||
const { invalidateProviderCache } = await import("@/src/lib/auth-server");
|
||||
await deleteOAuthProvider(id);
|
||||
invalidateProviderCache();
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function suppressWafRuleForHostAction(ruleId: number, hostname: string): Promise<ActionResult> {
|
||||
try {
|
||||
const session = await requireAdmin();
|
||||
|
||||
@@ -2,6 +2,8 @@ import SettingsClient from "./SettingsClient";
|
||||
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting, getUpstreamDnsResolutionSettings, getGeoBlockSettings } from "@/src/lib/settings";
|
||||
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
|
||||
import { listInstances } from "@/src/lib/models/instances";
|
||||
import { listOAuthProviders } from "@/src/lib/models/oauth-providers";
|
||||
import { config } from "@/src/lib/config";
|
||||
import { requireAdmin } from "@/src/lib/auth";
|
||||
|
||||
export default async function SettingsPage() {
|
||||
@@ -11,7 +13,7 @@ export default async function SettingsPage() {
|
||||
const modeFromEnv = isInstanceModeFromEnv();
|
||||
const tokenFromEnv = isSyncTokenFromEnv();
|
||||
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock] = await Promise.all([
|
||||
const [general, cloudflare, authentik, metrics, logging, dns, upstreamDnsResolution, instanceMode, globalGeoBlock, oauthProviders] = await Promise.all([
|
||||
getGeneralSettings(),
|
||||
getCloudflareSettings(),
|
||||
getAuthentikSettings(),
|
||||
@@ -21,6 +23,7 @@ export default async function SettingsPage() {
|
||||
getUpstreamDnsResolutionSettings(),
|
||||
getInstanceMode(),
|
||||
getGeoBlockSettings(),
|
||||
listOAuthProviders(),
|
||||
]);
|
||||
|
||||
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns, overrideUpstreamDnsResolution] =
|
||||
@@ -57,6 +60,8 @@ export default async function SettingsPage() {
|
||||
dns={dns}
|
||||
upstreamDnsResolution={upstreamDnsResolution}
|
||||
globalGeoBlock={globalGeoBlock}
|
||||
oauthProviders={oauthProviders}
|
||||
baseUrl={config.baseUrl}
|
||||
instanceSync={{
|
||||
mode: instanceMode,
|
||||
modeFromEnv,
|
||||
|
||||
@@ -22,12 +22,12 @@ type UserEntry = {
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url: string | null;
|
||||
provider: string | null;
|
||||
subject: string | null;
|
||||
avatarUrl: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -7,6 +7,6 @@ export default async function UsersPage() {
|
||||
const allUsers = await listUsers();
|
||||
// Strip password hashes before sending to client
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const safeUsers = allUsers.map(({ password_hash, ...rest }) => rest);
|
||||
const safeUsers = allUsers.map(({ passwordHash, ...rest }) => rest);
|
||||
return <UsersClient users={safeUsers} />;
|
||||
}
|
||||
|
||||
12
app/api/auth/[...all]/route.ts
Normal file
12
app/api/auth/[...all]/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { signOut, checkSameOrigin } from "@/src/lib/auth";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getAuth } from "@/src/lib/auth-server";
|
||||
import { checkSameOrigin } from "@/src/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originCheck = checkSameOrigin(request);
|
||||
if (originCheck) return originCheck;
|
||||
await signOut({ redirectTo: "/login" });
|
||||
|
||||
await getAuth().api.signOut({ headers: await headers() });
|
||||
return NextResponse.redirect(new URL("/login", request.url));
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// If user has a password, verify current password
|
||||
if (user.password_hash) {
|
||||
if (user.passwordHash) {
|
||||
if (!currentPassword) {
|
||||
return NextResponse.json(
|
||||
{ error: "Current password is required" },
|
||||
@@ -68,7 +68,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = bcrypt.compareSync(currentPassword, user.password_hash);
|
||||
const isValid = bcrypt.compareSync(currentPassword, user.passwordHash);
|
||||
if (!isValid) {
|
||||
registerFailedAttempt(rateLimitKey);
|
||||
return NextResponse.json(
|
||||
@@ -90,10 +90,10 @@ export async function POST(request: NextRequest) {
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId,
|
||||
action: user.password_hash ? "password_changed" : "password_set",
|
||||
action: user.passwordHash ? "password_changed" : "password_set",
|
||||
entityType: "user",
|
||||
entityId: userId,
|
||||
summary: user.password_hash ? "User changed their password" : "User set a password",
|
||||
summary: user.passwordHash ? "User changed their password" : "User set a password",
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -3,9 +3,8 @@ import { auth, checkSameOrigin } from "@/src/lib/auth";
|
||||
import { getUserById } from "@/src/lib/models/user";
|
||||
import { createAuditEvent } from "@/src/lib/models/audit";
|
||||
import db from "@/src/lib/db";
|
||||
import { users } from "@/src/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nowIso } from "@/src/lib/db";
|
||||
import { accounts } from "@/src/lib/db/schema";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const originCheck = checkSameOrigin(request);
|
||||
@@ -25,35 +24,37 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Must have a password before unlinking OAuth
|
||||
if (!user.password_hash) {
|
||||
if (!user.passwordHash) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot unlink OAuth: You must set a password first" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Must be using OAuth to unlink
|
||||
if (user.provider === "credentials") {
|
||||
// Check if user has any OAuth account links
|
||||
const oauthAccounts = await db.select().from(accounts).where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
ne(accounts.providerId, "credential")
|
||||
)
|
||||
);
|
||||
|
||||
if (oauthAccounts.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "No OAuth account to unlink" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const previousProvider = user.provider;
|
||||
const previousProvider = oauthAccounts[0].providerId;
|
||||
|
||||
// Revert to credentials-only
|
||||
const email = user.email;
|
||||
const username = email.replace(/@localhost$/, "") || email.split("@")[0];
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider: "credentials",
|
||||
subject: `${username}@localhost`,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Delete the OAuth account link(s)
|
||||
await db.delete(accounts).where(
|
||||
and(
|
||||
eq(accounts.userId, userId),
|
||||
ne(accounts.providerId, "credential")
|
||||
)
|
||||
);
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Update user avatar
|
||||
const updatedUser = await updateUserProfile(userId, {
|
||||
avatar_url: avatarUrl
|
||||
avatarUrl: avatarUrl
|
||||
});
|
||||
|
||||
if (!updatedUser) {
|
||||
@@ -69,7 +69,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
avatarUrl: updatedUser.avatar_url
|
||||
avatarUrl: updatedUser.avatarUrl
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar update error:", error);
|
||||
|
||||
120
app/api/v1/oauth-providers/[id]/route.ts
Normal file
120
app/api/v1/oauth-providers/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
app/api/v1/oauth-providers/route.ts
Normal file
71
app/api/v1/oauth-providers/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1982,7 +1982,7 @@ const spec = {
|
||||
},
|
||||
User: {
|
||||
type: "object",
|
||||
description: "User account (password_hash is never exposed)",
|
||||
description: "User account (passwordHash is never exposed)",
|
||||
properties: {
|
||||
id: { type: "integer" },
|
||||
email: { type: "string" },
|
||||
@@ -1990,12 +1990,12 @@ const spec = {
|
||||
role: { type: "string", enum: ["admin", "user", "viewer"] },
|
||||
provider: { type: "string", example: "credentials" },
|
||||
subject: { type: "string" },
|
||||
avatar_url: { type: ["string", "null"] },
|
||||
avatarUrl: { type: ["string", "null"] },
|
||||
status: { type: "string", enum: ["active", "inactive"] },
|
||||
created_at: { type: "string", format: "date-time" },
|
||||
updated_at: { type: "string", format: "date-time" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
updatedAt: { type: "string", format: "date-time" },
|
||||
},
|
||||
required: ["id", "email", "role", "provider", "subject", "status", "created_at", "updated_at"],
|
||||
required: ["id", "email", "role", "provider", "subject", "status", "createdAt", "updatedAt"],
|
||||
},
|
||||
AuditLogEvent: {
|
||||
type: "object",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { requireApiUser, requireApiAdmin, apiErrorResponse, ApiAuthError } from
|
||||
import { getUserById, updateUserProfile, updateUserRole, updateUserStatus, deleteUser } from "@/src/lib/models/user";
|
||||
|
||||
function stripPasswordHash(user: Record<string, unknown>) {
|
||||
const { password_hash: _, ...rest } = user;
|
||||
const { passwordHash: _, ...rest } = user;
|
||||
void _;
|
||||
return rest;
|
||||
}
|
||||
@@ -62,9 +62,9 @@ export async function PUT(
|
||||
const profileFields: Record<string, unknown> = {};
|
||||
if (body.email !== undefined) profileFields.email = body.email;
|
||||
if (body.name !== undefined) profileFields.name = body.name;
|
||||
if (body.avatar_url !== undefined) profileFields.avatar_url = body.avatar_url;
|
||||
if (body.avatarUrl !== undefined) profileFields.avatarUrl = body.avatarUrl;
|
||||
if (Object.keys(profileFields).length > 0) {
|
||||
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatar_url?: string | null });
|
||||
await updateUserProfile(targetId, profileFields as { email?: string; name?: string | null; avatarUrl?: string | null });
|
||||
}
|
||||
|
||||
const user = await getUserById(targetId);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { requireApiAdmin, apiErrorResponse } from "@/src/lib/api-auth";
|
||||
import { listUsers } from "@/src/lib/models/user";
|
||||
|
||||
function stripPasswordHash(user: Record<string, unknown>) {
|
||||
const { password_hash: _, ...rest } = user;
|
||||
const { passwordHash: _, ...rest } = user;
|
||||
void _;
|
||||
return rest;
|
||||
}
|
||||
|
||||
66
bun.lock
66
bun.lock
@@ -24,6 +24,7 @@
|
||||
"apexcharts": "^5.10.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -35,7 +36,6 @@
|
||||
"maplibre-gl": "^5.22.0",
|
||||
"maxmind": "^5.0.6",
|
||||
"next": "^16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.4.0",
|
||||
"postcss": "^8.5.9",
|
||||
@@ -77,8 +77,6 @@
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@auth/core": ["@auth/core@0.41.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -135,6 +133,24 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.6.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-nBftDp+eN1fwXor1O4KQorCXa0tJNDgpab7O1z4NcWUU+3faDpdzqLn5mbXZer2E8ZD4VhjqOfYZ041xnBF5NA=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-KawrNNuhgmpcc5PgLs6HesMckxCscz5J+BQ99iRmU1cLzG/A87IcydrmYtep+K8WHPN0HmZ/i4z/nOBCtxE2qA=="],
|
||||
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "kysely": "^0.27.0 || ^0.28.0" }, "optionalPeers": ["kysely"] }, "sha512-YMMm75jek/MNCAFWTAaq/U3VPmFnrwZW4NhBjjAwruHQJEIrSZZaOaUEXuUpFRRBhWqg7OOltQcHMwU/45CkuA=="],
|
||||
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0" } }, "sha512-QvuK5m7NFgkzLPHyab+NORu3J683nj36Tix58qq6DPcniyY6KZk5gY2yyh4+z1wgSjrxwY5NFx/DC2qz8B8NJg=="],
|
||||
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-IvR2Q+1pjzxA4JXI3ED76+6fsqervIpZ2K5MxoX/+miLQhLEmNcbqqcItg4O2kfkxN8h33/ev57sjTW8QH9Tuw=="],
|
||||
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-bQkXYTo1zPau+xAiMpo1yCjEDSy7i7oeYlkYO+fSfRDCo52DE/9oPOOuI+EStmFkPUNSk9L2rhk8Fulifi8WCg=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.2", "", { "peerDependencies": { "@better-auth/core": "^1.6.2", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-o4gHKXqizUxVUUYChZZTowLEzdsz3ViBE/fKFzfHqNFUnF+aVt8QsbLSfipq1WpTIXyJVT/SnH0hgSdWxdssbQ=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
"@clickhouse/client": ["@clickhouse/client@1.18.2", "", { "dependencies": { "@clickhouse/client-common": "1.18.2" } }, "sha512-fuquQswRSHWM6D079ZeuGqkMOsqtcUPL06UdTnowmoeeYjVrqisfVmvnw8pc3OeKS4kVb91oygb/MfLDiMs0TQ=="],
|
||||
|
||||
"@clickhouse/client-common": ["@clickhouse/client-common@1.18.2", "", {}, "sha512-J0SG6q9V31ydxonglpj9xhNRsUxCsF71iEZ784yldqMYwsHixj/9xHFDgBDX3DuMiDx/kPDfXnf+pimp08wIBA=="],
|
||||
@@ -359,11 +375,11 @@
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
"@noble/ciphers": ["@noble/ciphers@2.2.0", "", {}, "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
"@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -377,9 +393,11 @@
|
||||
|
||||
"@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="],
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.120.0", "", {}, "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||
|
||||
@@ -663,6 +681,10 @@
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||
|
||||
"better-auth": ["better-auth@1.6.2", "", { "dependencies": { "@better-auth/core": "1.6.2", "@better-auth/drizzle-adapter": "1.6.2", "@better-auth/kysely-adapter": "1.6.2", "@better-auth/memory-adapter": "1.6.2", "@better-auth/mongo-adapter": "1.6.2", "@better-auth/prisma-adapter": "1.6.2", "@better-auth/telemetry": "1.6.2", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-5nqDAIj5xexmnk+GjjdrBknJCabi1mlvsVWJbxs4usHreao4vNdxIxINWDzCyDF9iDR1ildRZdXWSiYPAvTHhA=="],
|
||||
|
||||
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
|
||||
|
||||
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
|
||||
|
||||
"bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="],
|
||||
@@ -779,6 +801,8 @@
|
||||
|
||||
"define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="],
|
||||
|
||||
"defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -1063,6 +1087,8 @@
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
@@ -1149,6 +1175,8 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"nanostores": ["nanostores@1.2.0", "", {}, "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg=="],
|
||||
|
||||
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
@@ -1157,8 +1185,6 @@
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
|
||||
"next-auth": ["next-auth@5.0.0-beta.30", "", { "dependencies": { "@auth/core": "0.41.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0", "nodemailer": "^7.0.7", "react": "^18.2.0 || ^19.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="],
|
||||
@@ -1173,8 +1199,6 @@
|
||||
|
||||
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
|
||||
|
||||
"oauth4webapi": ["oauth4webapi@3.8.5", "", {}, "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -1241,10 +1265,6 @@
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
|
||||
|
||||
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
@@ -1315,6 +1335,8 @@
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "=0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="],
|
||||
|
||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
@@ -1335,6 +1357,8 @@
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
@@ -1549,12 +1573,16 @@
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||
|
||||
"@ecies/ciphers/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
@@ -1563,6 +1591,8 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
@@ -1615,6 +1645,8 @@
|
||||
|
||||
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
@@ -1623,6 +1655,10 @@
|
||||
|
||||
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
|
||||
|
||||
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
71
drizzle/0020_better_auth.sql
Normal file
71
drizzle/0020_better_auth.sql
Normal 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`);
|
||||
178
drizzle/0021_camelcase_columns.sql
Normal file
178
drizzle/0021_camelcase_columns.sql
Normal 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";
|
||||
27
drizzle/0022_nullable_provider_subject.sql
Normal file
27
drizzle/0022_nullable_provider_subject.sql
Normal 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);
|
||||
@@ -141,6 +141,27 @@
|
||||
"when": 1775700000000,
|
||||
"tag": "0019_drop_analytics_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "6",
|
||||
"when": 1776000000000,
|
||||
"tag": "0020_better_auth",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "6",
|
||||
"when": 1776100000000,
|
||||
"tag": "0021_camelcase_columns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 22,
|
||||
"version": "6",
|
||||
"when": 1776200000000,
|
||||
"tag": "0022_nullable_provider_subject",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
12804
package-lock.json
generated
12804
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,7 @@
|
||||
"apexcharts": "^5.10.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.6.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -50,7 +51,6 @@
|
||||
"maplibre-gl": "^5.22.0",
|
||||
"maxmind": "^5.0.6",
|
||||
"next": "^16.2.3",
|
||||
"next-auth": "5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-forge": "^1.4.0",
|
||||
"postcss": "^8.5.9",
|
||||
|
||||
12
proxy.ts
12
proxy.ts
@@ -1,6 +1,7 @@
|
||||
import { auth } from "@/src/lib/auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
import { auth } from "@/src/lib/auth";
|
||||
|
||||
/**
|
||||
* Next.js Proxy for route protection.
|
||||
@@ -34,8 +35,7 @@ function buildCsp(nonce: string): string {
|
||||
return directives.join("; ");
|
||||
}
|
||||
|
||||
export default auth((req) => {
|
||||
const isAuthenticated = !!req.auth;
|
||||
export default async function middleware(req: NextRequest) {
|
||||
const pathname = req.nextUrl.pathname;
|
||||
|
||||
// Allow public routes
|
||||
@@ -51,6 +51,10 @@ export default auth((req) => {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check authentication for protected routes
|
||||
const session = await auth(req);
|
||||
const isAuthenticated = !!session?.user;
|
||||
|
||||
// Redirect unauthenticated users to login
|
||||
if (!isAuthenticated && !pathname.startsWith("/login")) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
@@ -77,7 +81,7 @@ export default auth((req) => {
|
||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
|
||||
@@ -234,7 +234,7 @@ export function ManageIssuedClientCertsDialog({
|
||||
const result = await revokeIssuedClientCertificateAction(id);
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
item.id === id ? { ...item, revoked_at: result.revokedAt, updated_at: result.revokedAt } : item
|
||||
item.id === id ? { ...item, revokedAt: result.revokedAt, updatedAt: result.revokedAt } : item
|
||||
)
|
||||
);
|
||||
router.refresh();
|
||||
@@ -244,8 +244,8 @@ export function ManageIssuedClientCertsDialog({
|
||||
});
|
||||
}
|
||||
|
||||
const visibleItems = showRevoked ? items : items.filter((i) => !i.revoked_at);
|
||||
const revokedCount = items.filter((i) => i.revoked_at).length;
|
||||
const visibleItems = showRevoked ? items : items.filter((i) => !i.revokedAt);
|
||||
const revokedCount = items.filter((i) => i.revokedAt).length;
|
||||
|
||||
return (
|
||||
<AppDialog
|
||||
@@ -285,31 +285,31 @@ export function ManageIssuedClientCertsDialog({
|
||||
</p>
|
||||
) : (
|
||||
visibleItems.map((item) => {
|
||||
const expired = new Date(item.valid_to).getTime() < Date.now();
|
||||
const expired = new Date(item.validTo).getTime() < Date.now();
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-base">{item.common_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Serial {item.serial_number}</p>
|
||||
<p className="font-semibold text-base">{item.commonName}</p>
|
||||
<p className="text-sm text-muted-foreground">Serial {item.serialNumber}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 justify-end">
|
||||
<Badge variant={item.revoked_at ? "secondary" : "default"}>
|
||||
{item.revoked_at ? "Revoked" : "Active"}
|
||||
<Badge variant={item.revokedAt ? "secondary" : "default"}>
|
||||
{item.revokedAt ? "Revoked" : "Active"}
|
||||
</Badge>
|
||||
<Badge variant={expired ? "destructive" : "outline"}>
|
||||
{expired
|
||||
? `Expired ${formatDateTime(item.valid_to)}`
|
||||
: `Expires ${formatDateTime(item.valid_to)}`}
|
||||
? `Expired ${formatDateTime(item.validTo)}`
|
||||
: `Expires ${formatDateTime(item.validTo)}`}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Issued {formatDateTime(item.created_at)}</p>
|
||||
<p className="text-sm text-muted-foreground">Issued {formatDateTime(item.createdAt)}</p>
|
||||
<p className="text-sm text-muted-foreground font-mono break-all">
|
||||
SHA-256 {formatFingerprint(item.fingerprint_sha256)}
|
||||
SHA-256 {formatFingerprint(item.fingerprintSha256)}
|
||||
</p>
|
||||
{item.revoked_at ? (
|
||||
<p className="text-sm text-muted-foreground">Revoked {formatDateTime(item.revoked_at)}</p>
|
||||
{item.revokedAt ? (
|
||||
<p className="text-sm text-muted-foreground">Revoked {formatDateTime(item.revokedAt)}</p>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
|
||||
@@ -68,20 +68,20 @@ function L4HostForm({
|
||||
const [enabled, setEnabled] = useState(initialData?.enabled ?? true);
|
||||
const [protocol, setProtocol] = useState(initialData?.protocol ?? "tcp");
|
||||
const [matcherType, setMatcherType] = useState(
|
||||
initialData?.matcher_type ?? "none"
|
||||
initialData?.matcherType ?? "none"
|
||||
);
|
||||
|
||||
const defaultLbAccordion = initialData?.load_balancer?.enabled
|
||||
const defaultLbAccordion = initialData?.loadBalancer?.enabled
|
||||
? "load-balancer"
|
||||
: undefined;
|
||||
const defaultDnsAccordion = initialData?.dns_resolver?.enabled
|
||||
const defaultDnsAccordion = initialData?.dnsResolver?.enabled
|
||||
? "dns-resolver"
|
||||
: undefined;
|
||||
const defaultGeoblockAccordion = initialData?.geoblock?.enabled
|
||||
? "geoblock"
|
||||
: undefined;
|
||||
const defaultUpstreamDnsAccordion =
|
||||
initialData?.upstream_dns_resolution?.enabled === true
|
||||
initialData?.upstreamDnsResolution?.enabled === true
|
||||
? "upstream-dns"
|
||||
: undefined;
|
||||
|
||||
@@ -163,7 +163,7 @@ function L4HostForm({
|
||||
id="listen_address"
|
||||
name="listen_address"
|
||||
placeholder=":5432"
|
||||
defaultValue={initialData?.listen_address ?? ""}
|
||||
defaultValue={initialData?.listenAddress ?? ""}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
@@ -220,7 +220,7 @@ function L4HostForm({
|
||||
id="matcher_value"
|
||||
name="matcher_value"
|
||||
placeholder="db.example.com, api.example.com"
|
||||
defaultValue={initialData?.matcher_value?.join(", ") ?? ""}
|
||||
defaultValue={initialData?.matcherValue?.join(", ") ?? ""}
|
||||
required
|
||||
/>
|
||||
</FormField>
|
||||
@@ -231,7 +231,7 @@ function L4HostForm({
|
||||
<Switch
|
||||
id="tls_termination"
|
||||
name="tls_termination"
|
||||
defaultChecked={initialData?.tls_termination ?? false}
|
||||
defaultChecked={initialData?.tlsTermination ?? false}
|
||||
/>
|
||||
<Label htmlFor="tls_termination">TLS Termination</Label>
|
||||
</div>
|
||||
@@ -241,7 +241,7 @@ function L4HostForm({
|
||||
<Switch
|
||||
id="proxy_protocol_receive"
|
||||
name="proxy_protocol_receive"
|
||||
defaultChecked={initialData?.proxy_protocol_receive ?? false}
|
||||
defaultChecked={initialData?.proxyProtocolReceive ?? false}
|
||||
/>
|
||||
<Label htmlFor="proxy_protocol_receive">
|
||||
Accept inbound PROXY protocol
|
||||
@@ -254,7 +254,7 @@ function L4HostForm({
|
||||
</Label>
|
||||
<Select
|
||||
name="proxy_protocol_version"
|
||||
defaultValue={initialData?.proxy_protocol_version ?? "__none__"}
|
||||
defaultValue={initialData?.proxyProtocolVersion ?? "__none__"}
|
||||
>
|
||||
<SelectTrigger id="proxy_protocol_version">
|
||||
<SelectValue placeholder="None" />
|
||||
@@ -292,7 +292,7 @@ function L4HostForm({
|
||||
id="lb_enabled"
|
||||
name="lb_enabled"
|
||||
defaultChecked={
|
||||
initialData?.load_balancer?.enabled ?? false
|
||||
initialData?.loadBalancer?.enabled ?? false
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="lb_enabled">Enable Load Balancing</Label>
|
||||
@@ -302,7 +302,7 @@ function L4HostForm({
|
||||
<Select
|
||||
name="lb_policy"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.policy ?? "random"
|
||||
initialData?.loadBalancer?.policy ?? "random"
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="lb_policy">
|
||||
@@ -325,7 +325,7 @@ function L4HostForm({
|
||||
name="lb_try_duration"
|
||||
placeholder="5s"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.tryDuration ?? ""
|
||||
initialData?.loadBalancer?.tryDuration ?? ""
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -335,7 +335,7 @@ function L4HostForm({
|
||||
name="lb_try_interval"
|
||||
placeholder="250ms"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.tryInterval ?? ""
|
||||
initialData?.loadBalancer?.tryInterval ?? ""
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -344,7 +344,7 @@ function L4HostForm({
|
||||
id="lb_retries"
|
||||
name="lb_retries"
|
||||
type="number"
|
||||
defaultValue={initialData?.load_balancer?.retries ?? ""}
|
||||
defaultValue={initialData?.loadBalancer?.retries ?? ""}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
@@ -361,7 +361,7 @@ function L4HostForm({
|
||||
id="lb_active_health_enabled"
|
||||
name="lb_active_health_enabled"
|
||||
defaultChecked={
|
||||
initialData?.load_balancer?.activeHealthCheck?.enabled ??
|
||||
initialData?.loadBalancer?.activeHealthCheck?.enabled ??
|
||||
false
|
||||
}
|
||||
/>
|
||||
@@ -378,7 +378,7 @@ function L4HostForm({
|
||||
name="lb_active_health_port"
|
||||
type="number"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.activeHealthCheck?.port ?? ""
|
||||
initialData?.loadBalancer?.activeHealthCheck?.port ?? ""
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -388,7 +388,7 @@ function L4HostForm({
|
||||
name="lb_active_health_interval"
|
||||
placeholder="30s"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.activeHealthCheck?.interval ??
|
||||
initialData?.loadBalancer?.activeHealthCheck?.interval ??
|
||||
""
|
||||
}
|
||||
/>
|
||||
@@ -399,7 +399,7 @@ function L4HostForm({
|
||||
name="lb_active_health_timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.activeHealthCheck?.timeout ?? ""
|
||||
initialData?.loadBalancer?.activeHealthCheck?.timeout ?? ""
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
@@ -417,7 +417,7 @@ function L4HostForm({
|
||||
id="lb_passive_health_enabled"
|
||||
name="lb_passive_health_enabled"
|
||||
defaultChecked={
|
||||
initialData?.load_balancer?.passiveHealthCheck?.enabled ??
|
||||
initialData?.loadBalancer?.passiveHealthCheck?.enabled ??
|
||||
false
|
||||
}
|
||||
/>
|
||||
@@ -434,7 +434,7 @@ function L4HostForm({
|
||||
name="lb_passive_health_fail_duration"
|
||||
placeholder="30s"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.passiveHealthCheck
|
||||
initialData?.loadBalancer?.passiveHealthCheck
|
||||
?.failDuration ?? ""
|
||||
}
|
||||
/>
|
||||
@@ -445,7 +445,7 @@ function L4HostForm({
|
||||
name="lb_passive_health_max_fails"
|
||||
type="number"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.passiveHealthCheck?.maxFails ??
|
||||
initialData?.loadBalancer?.passiveHealthCheck?.maxFails ??
|
||||
""
|
||||
}
|
||||
/>
|
||||
@@ -459,7 +459,7 @@ function L4HostForm({
|
||||
name="lb_passive_health_unhealthy_latency"
|
||||
placeholder="5s"
|
||||
defaultValue={
|
||||
initialData?.load_balancer?.passiveHealthCheck
|
||||
initialData?.loadBalancer?.passiveHealthCheck
|
||||
?.unhealthyLatency ?? ""
|
||||
}
|
||||
/>
|
||||
@@ -493,7 +493,7 @@ function L4HostForm({
|
||||
<Switch
|
||||
id="dns_enabled"
|
||||
name="dns_enabled"
|
||||
defaultChecked={initialData?.dns_resolver?.enabled ?? false}
|
||||
defaultChecked={initialData?.dnsResolver?.enabled ?? false}
|
||||
/>
|
||||
<Label htmlFor="dns_enabled">Enable Custom DNS</Label>
|
||||
</div>
|
||||
@@ -507,7 +507,7 @@ function L4HostForm({
|
||||
name="dns_resolvers"
|
||||
placeholder={"1.1.1.1\n8.8.8.8"}
|
||||
defaultValue={
|
||||
initialData?.dns_resolver?.resolvers?.join("\n") ?? ""
|
||||
initialData?.dnsResolver?.resolvers?.join("\n") ?? ""
|
||||
}
|
||||
rows={2}
|
||||
/>
|
||||
@@ -522,7 +522,7 @@ function L4HostForm({
|
||||
name="dns_fallbacks"
|
||||
placeholder="8.8.4.4"
|
||||
defaultValue={
|
||||
initialData?.dns_resolver?.fallbacks?.join("\n") ?? ""
|
||||
initialData?.dnsResolver?.fallbacks?.join("\n") ?? ""
|
||||
}
|
||||
rows={1}
|
||||
/>
|
||||
@@ -532,7 +532,7 @@ function L4HostForm({
|
||||
id="dns_timeout"
|
||||
name="dns_timeout"
|
||||
placeholder="5s"
|
||||
defaultValue={initialData?.dns_resolver?.timeout ?? ""}
|
||||
defaultValue={initialData?.dnsResolver?.timeout ?? ""}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -571,7 +571,7 @@ function L4HostForm({
|
||||
<Label htmlFor="geoblock_mode">Mode</Label>
|
||||
<Select
|
||||
name="geoblock_mode"
|
||||
defaultValue={initialData?.geoblock_mode ?? "merge"}
|
||||
defaultValue={initialData?.geoblockMode ?? "merge"}
|
||||
>
|
||||
<SelectTrigger id="geoblock_mode">
|
||||
<SelectValue />
|
||||
@@ -752,9 +752,9 @@ function L4HostForm({
|
||||
<Select
|
||||
name="upstream_dns_resolution_mode"
|
||||
defaultValue={
|
||||
initialData?.upstream_dns_resolution?.enabled === true
|
||||
initialData?.upstreamDnsResolution?.enabled === true
|
||||
? "enabled"
|
||||
: initialData?.upstream_dns_resolution?.enabled === false
|
||||
: initialData?.upstreamDnsResolution?.enabled === false
|
||||
? "disabled"
|
||||
: "inherit"
|
||||
}
|
||||
@@ -778,7 +778,7 @@ function L4HostForm({
|
||||
<Select
|
||||
name="upstream_dns_resolution_family"
|
||||
defaultValue={
|
||||
initialData?.upstream_dns_resolution?.family ?? "inherit"
|
||||
initialData?.upstreamDnsResolution?.family ?? "inherit"
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="upstream_dns_resolution_family">
|
||||
@@ -948,7 +948,7 @@ export function DeleteL4HostDialog({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-20 shrink-0">Listen</span>
|
||||
<span className="font-mono text-xs">{host.listen_address}</span>
|
||||
<span className="font-mono text-xs">{host.listenAddress}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-20 shrink-0">Upstreams</span>
|
||||
|
||||
@@ -29,7 +29,7 @@ type Props = {
|
||||
|
||||
export function MtlsRolesTab({ roles, issuedCerts, search }: Props) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const activeCerts = issuedCerts.filter(c => !c.revoked_at);
|
||||
const activeCerts = issuedCerts.filter(c => !c.revokedAt);
|
||||
|
||||
const filtered = roles.filter(r =>
|
||||
!search ||
|
||||
@@ -128,7 +128,7 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
|
||||
const loadAssignments = useCallback(() => {
|
||||
fetch(`/api/v1/mtls-roles/${role.id}`)
|
||||
.then(r => r.ok ? r.json() : { certificate_ids: [] })
|
||||
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificate_ids)); setLoaded(true); })
|
||||
.then((data: MtlsRoleWithCertificates) => { setAssignedIds(new Set(data.certificateIds)); setLoaded(true); })
|
||||
.catch(() => setLoaded(true));
|
||||
}, [role.id]);
|
||||
|
||||
@@ -143,7 +143,7 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
|
||||
setAssignedIds(prev => { const next = new Set(prev); next.delete(certId); return next; });
|
||||
} else {
|
||||
await fetch(`/api/v1/mtls-roles/${role.id}/certificates`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificate_id: certId }),
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ certificateId: certId }),
|
||||
});
|
||||
setAssignedIds(prev => new Set(prev).add(certId));
|
||||
}
|
||||
@@ -234,9 +234,9 @@ function RoleCard({ role, accent, activeCerts }: { role: MtlsRole; accent: typeo
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Checkbox checked={isAssigned} disabled={isLoading} onCheckedChange={() => handleToggle(cert.id)} />
|
||||
<div>
|
||||
<p className="text-sm font-medium leading-tight">{cert.common_name}</p>
|
||||
<p className="text-sm font-medium leading-tight">{cert.commonName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
expires {new Date(cert.valid_to).toLocaleDateString()}
|
||||
expires {new Date(cert.validTo).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function CpmForwardAuthFields({
|
||||
groups = [],
|
||||
currentAccess,
|
||||
}: {
|
||||
cpmForwardAuth?: ProxyHost["cpm_forward_auth"] | null;
|
||||
cpmForwardAuth?: ProxyHost["cpmForwardAuth"] | null;
|
||||
users?: UserEntry[];
|
||||
groups?: GroupEntry[];
|
||||
currentAccess?: ForwardAuthAccessData | null;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ProxyHost } from "@/lib/models/proxy-hosts";
|
||||
export function DnsResolverFields({
|
||||
dnsResolver
|
||||
}: {
|
||||
dnsResolver?: ProxyHost["dns_resolver"] | null;
|
||||
dnsResolver?: ProxyHost["dnsResolver"] | null;
|
||||
}) {
|
||||
const initial = dnsResolver ?? null;
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
|
||||
|
||||
@@ -87,8 +87,8 @@ export function CreateHostDialog({
|
||||
</Alert>
|
||||
)}
|
||||
<SettingsToggles
|
||||
hstsSubdomains={initialData?.hsts_subdomains}
|
||||
skipHttpsValidation={initialData?.skip_https_hostname_validation}
|
||||
hstsSubdomains={initialData?.hstsSubdomains}
|
||||
skipHttpsValidation={initialData?.skipHttpsHostnameValidation}
|
||||
enabled={true}
|
||||
/>
|
||||
<div>
|
||||
@@ -118,7 +118,7 @@ export function CreateHostDialog({
|
||||
<UpstreamInput defaultUpstreams={initialData?.upstreams} />
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(initialData?.certificate_id ?? "__none__")}>
|
||||
<Select name="certificate_id" defaultValue={String(initialData?.certificateId ?? "__none__")}>
|
||||
<SelectTrigger aria-label="Certificate">
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
@@ -134,7 +134,7 @@ export function CreateHostDialog({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(initialData?.access_list_id ?? "__none__")}>
|
||||
<Select name="access_list_id" defaultValue={String(initialData?.accessListId ?? "__none__")}>
|
||||
<SelectTrigger aria-label="Access List">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
@@ -149,14 +149,14 @@ export function CreateHostDialog({
|
||||
</Select>
|
||||
</div>
|
||||
<RedirectsFields initialData={initialData?.redirects} />
|
||||
<LocationRulesFields initialData={initialData?.location_rules} />
|
||||
<LocationRulesFields initialData={initialData?.locationRules} />
|
||||
<RewriteFields initialData={initialData?.rewrite} />
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_pre_handlers_json"
|
||||
placeholder='[{"handler": "headers", ...}]'
|
||||
defaultValue={initialData?.custom_pre_handlers_json ?? ""}
|
||||
defaultValue={initialData?.customPreHandlersJson ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
|
||||
@@ -166,7 +166,7 @@ export function CreateHostDialog({
|
||||
<Textarea
|
||||
name="custom_reverse_proxy_json"
|
||||
placeholder='{"headers": {"request": {...}}}'
|
||||
defaultValue={initialData?.custom_reverse_proxy_json ?? ""}
|
||||
defaultValue={initialData?.customReverseProxyJson ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -175,13 +175,13 @@ export function CreateHostDialog({
|
||||
</div>
|
||||
<AuthentikFields defaults={authentikDefaults} authentik={initialData?.authentik} />
|
||||
<CpmForwardAuthFields
|
||||
cpmForwardAuth={initialData?.cpm_forward_auth}
|
||||
cpmForwardAuth={initialData?.cpmForwardAuth}
|
||||
users={forwardAuthUsers}
|
||||
groups={forwardAuthGroups}
|
||||
/>
|
||||
<LoadBalancerFields loadBalancer={initialData?.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={initialData?.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstream_dns_resolution} />
|
||||
<LoadBalancerFields loadBalancer={initialData?.loadBalancer} />
|
||||
<DnsResolverFields dnsResolver={initialData?.dnsResolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={initialData?.upstreamDnsResolution} />
|
||||
<GeoBlockFields />
|
||||
<WafFields value={initialData?.waf} />
|
||||
<MtlsFields
|
||||
@@ -246,8 +246,8 @@ export function EditHostDialog({
|
||||
</Alert>
|
||||
)}
|
||||
<SettingsToggles
|
||||
hstsSubdomains={host.hsts_subdomains}
|
||||
skipHttpsValidation={host.skip_https_hostname_validation}
|
||||
hstsSubdomains={host.hstsSubdomains}
|
||||
skipHttpsValidation={host.skipHttpsHostnameValidation}
|
||||
enabled={host.enabled}
|
||||
/>
|
||||
<div>
|
||||
@@ -269,7 +269,7 @@ export function EditHostDialog({
|
||||
<UpstreamInput defaultUpstreams={host.upstreams} />
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Certificate</label>
|
||||
<Select name="certificate_id" defaultValue={String(host.certificate_id ?? "__none__")}>
|
||||
<Select name="certificate_id" defaultValue={String(host.certificateId ?? "__none__")}>
|
||||
<SelectTrigger aria-label="Certificate">
|
||||
<SelectValue placeholder="Managed by Caddy (Auto)" />
|
||||
</SelectTrigger>
|
||||
@@ -285,7 +285,7 @@ export function EditHostDialog({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Access List</label>
|
||||
<Select name="access_list_id" defaultValue={String(host.access_list_id ?? "__none__")}>
|
||||
<Select name="access_list_id" defaultValue={String(host.accessListId ?? "__none__")}>
|
||||
<SelectTrigger aria-label="Access List">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
@@ -300,13 +300,13 @@ export function EditHostDialog({
|
||||
</Select>
|
||||
</div>
|
||||
<RedirectsFields initialData={host.redirects} />
|
||||
<LocationRulesFields initialData={host.location_rules} />
|
||||
<LocationRulesFields initialData={host.locationRules} />
|
||||
<RewriteFields initialData={host.rewrite} />
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1 block">Custom Pre-Handlers (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_pre_handlers_json"
|
||||
defaultValue={host.custom_pre_handlers_json ?? ""}
|
||||
defaultValue={host.customPreHandlersJson ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Optional JSON array of Caddy handlers</p>
|
||||
@@ -315,7 +315,7 @@ export function EditHostDialog({
|
||||
<label className="text-sm font-medium mb-1 block">Custom Reverse Proxy (JSON)</label>
|
||||
<Textarea
|
||||
name="custom_reverse_proxy_json"
|
||||
defaultValue={host.custom_reverse_proxy_json ?? ""}
|
||||
defaultValue={host.customReverseProxyJson ?? ""}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
@@ -324,18 +324,18 @@ export function EditHostDialog({
|
||||
</div>
|
||||
<AuthentikFields authentik={host.authentik} />
|
||||
<CpmForwardAuthFields
|
||||
cpmForwardAuth={host.cpm_forward_auth}
|
||||
cpmForwardAuth={host.cpmForwardAuth}
|
||||
users={forwardAuthUsers}
|
||||
groups={forwardAuthGroups}
|
||||
currentAccess={forwardAuthAccess}
|
||||
/>
|
||||
<LoadBalancerFields loadBalancer={host.load_balancer} />
|
||||
<DnsResolverFields dnsResolver={host.dns_resolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstream_dns_resolution} />
|
||||
<LoadBalancerFields loadBalancer={host.loadBalancer} />
|
||||
<DnsResolverFields dnsResolver={host.dnsResolver} />
|
||||
<UpstreamDnsResolutionFields upstreamDnsResolution={host.upstreamDnsResolution} />
|
||||
<GeoBlockFields
|
||||
initialValues={{
|
||||
geoblock: host.geoblock,
|
||||
geoblock_mode: host.geoblock_mode,
|
||||
geoblock_mode: host.geoblockMode,
|
||||
}}
|
||||
/>
|
||||
<WafFields value={host.waf} />
|
||||
|
||||
@@ -19,7 +19,7 @@ const LOAD_BALANCING_POLICIES = [
|
||||
export function LoadBalancerFields({
|
||||
loadBalancer
|
||||
}: {
|
||||
loadBalancer?: ProxyHost["load_balancer"] | null;
|
||||
loadBalancer?: ProxyHost["loadBalancer"] | null;
|
||||
}) {
|
||||
const initial = loadBalancer ?? null;
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
|
||||
|
||||
@@ -37,13 +37,13 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
|
||||
const [editRule, setEditRule] = useState<MtlsAccessRule | null>(null);
|
||||
|
||||
const isEditMode = !!proxyHostId;
|
||||
const activeCerts = issuedClientCerts.filter(c => !c.revoked_at);
|
||||
const activeCerts = issuedClientCerts.filter(c => !c.revokedAt);
|
||||
|
||||
const certsByCA = new Map<number, IssuedClientCertificate[]>();
|
||||
for (const cert of activeCerts) {
|
||||
const list = certsByCA.get(cert.ca_certificate_id) ?? [];
|
||||
const list = certsByCA.get(cert.caCertificateId) ?? [];
|
||||
list.push(cert);
|
||||
certsByCA.set(cert.ca_certificate_id, list);
|
||||
certsByCA.set(cert.caCertificateId, list);
|
||||
}
|
||||
|
||||
const loadRules = useCallback(() => {
|
||||
@@ -143,7 +143,7 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
|
||||
<span className="text-sm font-medium">{role.name}</span>
|
||||
{role.description && <span className="text-xs text-muted-foreground ml-2">— {role.description}</span>}
|
||||
</label>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{role.certificate_count} certs</Badge>
|
||||
<Badge variant="outline" className="text-xs shrink-0">{role.certificateCount} certs</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -201,10 +201,10 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
|
||||
className="ml-4"
|
||||
/>
|
||||
<label className="min-w-0 flex-1 cursor-pointer" onClick={() => toggleCert(cert.id)}>
|
||||
<span className="text-sm">{cert.common_name}</span>
|
||||
<span className="text-sm">{cert.commonName}</span>
|
||||
</label>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
expires {new Date(cert.valid_to).toLocaleDateString()}
|
||||
expires {new Date(cert.validTo).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -249,20 +249,20 @@ export function MtlsFields({ value, caCertificates, issuedClientCerts = [], prox
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{rules.map(rule => (
|
||||
<div key={rule.id} className="group flex items-center gap-2 rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.path_pattern}</code>
|
||||
{rule.deny_all ? (
|
||||
<code className="shrink-0 text-xs bg-muted px-1.5 py-0.5 rounded font-mono">{rule.pathPattern}</code>
|
||||
{rule.denyAll ? (
|
||||
<Badge variant="destructive" className="text-xs gap-1"><Ban className="h-3 w-3" /> Deny</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 flex-1 min-w-0">
|
||||
{rule.allowed_role_ids.map(roleId => {
|
||||
{rule.allowedRoleIds.map(roleId => {
|
||||
const role = mtlsRoles.find(r => r.id === roleId);
|
||||
return <Badge key={`r-${roleId}`} variant="secondary" className="text-xs">{role?.name ?? `#${roleId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_cert_ids.map(certId => {
|
||||
{rule.allowedCertIds.map(certId => {
|
||||
const cert = issuedClientCerts.find(c => c.id === certId);
|
||||
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.common_name ?? `#${certId}`}</Badge>;
|
||||
return <Badge key={`c-${certId}`} variant="outline" className="text-xs">{cert?.commonName ?? `#${certId}`}</Badge>;
|
||||
})}
|
||||
{rule.allowed_role_ids.length === 0 && rule.allowed_cert_ids.length === 0 && (
|
||||
{rule.allowedRoleIds.length === 0 && rule.allowedCertIds.length === 0 && (
|
||||
<span className="text-xs text-destructive italic">No roles/certs — effectively denied</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -297,12 +297,12 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
|
||||
onClose: () => void; proxyHostId: number; roles: MtlsRole[]; activeCerts: IssuedClientCertificate[];
|
||||
title: string; submitLabel: string; existing?: MtlsAccessRule; onSaved: () => void;
|
||||
}) {
|
||||
const [pathPattern, setPathPattern] = useState(existing?.path_pattern ?? "*");
|
||||
const [pathPattern, setPathPattern] = useState(existing?.pathPattern ?? "*");
|
||||
const [priority, setPriority] = useState(String(existing?.priority ?? 0));
|
||||
const [description, setDescription] = useState(existing?.description ?? "");
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowed_role_ids ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowed_cert_ids ?? []);
|
||||
const [denyAll, setDenyAll] = useState(existing?.deny_all ?? false);
|
||||
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>(existing?.allowedRoleIds ?? []);
|
||||
const [selectedCertIds, setSelectedCertIds] = useState<number[]>(existing?.allowedCertIds ?? []);
|
||||
const [denyAll, setDenyAll] = useState(existing?.denyAll ?? false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -373,7 +373,7 @@ function RuleDialog({ onClose, proxyHostId, roles, activeCerts, title, submitLab
|
||||
{activeCerts.map(cert => (
|
||||
<div key={cert.id} className="flex items-center gap-2 py-1 rounded hover:bg-muted/50 px-1">
|
||||
<Checkbox checked={selectedCertIds.includes(cert.id)} onCheckedChange={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])} />
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.common_name}</label>
|
||||
<label className="text-sm cursor-pointer flex-1" onClick={() => setSelectedCertIds(prev => prev.includes(cert.id) ? prev.filter(i => i !== cert.id) : [...prev, cert.id])}>{cert.commonName}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
|
||||
type ToggleSetting = {
|
||||
name: "hsts_subdomains" | "skip_https_hostname_validation";
|
||||
name: "hstsSubdomains" | "skipHttpsHostnameValidation";
|
||||
label: string;
|
||||
description: string;
|
||||
defaultChecked: boolean;
|
||||
@@ -21,8 +21,8 @@ export function SettingsToggles({
|
||||
enabled = true
|
||||
}: SettingsTogglesProps) {
|
||||
const [values, setValues] = useState({
|
||||
hsts_subdomains: hstsSubdomains,
|
||||
skip_https_hostname_validation: skipHttpsValidation,
|
||||
hstsSubdomains: hstsSubdomains,
|
||||
skipHttpsHostnameValidation: skipHttpsValidation,
|
||||
enabled: enabled
|
||||
});
|
||||
|
||||
@@ -32,16 +32,16 @@ export function SettingsToggles({
|
||||
|
||||
const settings: ToggleSetting[] = [
|
||||
{
|
||||
name: "hsts_subdomains",
|
||||
name: "hstsSubdomains",
|
||||
label: "HSTS Subdomains",
|
||||
description: "Include subdomains in the Strict-Transport-Security header",
|
||||
defaultChecked: values.hsts_subdomains,
|
||||
defaultChecked: values.hstsSubdomains,
|
||||
},
|
||||
{
|
||||
name: "skip_https_hostname_validation",
|
||||
name: "skipHttpsHostnameValidation",
|
||||
label: "Skip HTTPS Validation",
|
||||
description: "Skip SSL certificate hostname verification for backend connections",
|
||||
defaultChecked: values.skip_https_hostname_validation,
|
||||
defaultChecked: values.skipHttpsHostnameValidation,
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function toFamilyMode(family: "ipv6" | "ipv4" | "both" | null | undefined): Fami
|
||||
export function UpstreamDnsResolutionFields({
|
||||
upstreamDnsResolution
|
||||
}: {
|
||||
upstreamDnsResolution?: ProxyHost["upstream_dns_resolution"] | null;
|
||||
upstreamDnsResolution?: ProxyHost["upstreamDnsResolution"] | null;
|
||||
}) {
|
||||
const mode = toResolutionMode(upstreamDnsResolution?.enabled);
|
||||
const family = toFamilyMode(upstreamDnsResolution?.family);
|
||||
|
||||
6
src/lib/auth-client.ts
Normal file
6
src/lib/auth-client.ts
Normal 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
152
src/lib/auth-server.ts
Normal 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;
|
||||
}
|
||||
494
src/lib/auth.ts
494
src/lib/auth.ts
@@ -1,431 +1,92 @@
|
||||
import NextAuth, { type DefaultSession } from "next-auth";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import type { OAuthConfig } from "next-auth/providers";
|
||||
import bcrypt from "bcryptjs";
|
||||
import db from "./db";
|
||||
import { config } from "./config";
|
||||
import { findUserByProviderSubject, createUser, getUserById } from "./models/user";
|
||||
import { createAuditEvent } from "./models/audit";
|
||||
import { decideLinkingStrategy, createLinkingToken, storeLinkingToken, autoLinkOAuth, linkOAuthAuthenticated } from "./services/account-linking";
|
||||
import { getAuth } from "./auth-server";
|
||||
import { getUserById } from "./models/user";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
role: string;
|
||||
provider?: string;
|
||||
} & DefaultSession["user"];
|
||||
export type Session = {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
provider?: string;
|
||||
image?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current session, optionally from a specific request.
|
||||
*
|
||||
* - `auth()` — uses `headers()` from next/headers (server components, route handlers)
|
||||
* - `auth(req)` — uses request headers (middleware)
|
||||
*
|
||||
* Returns `Session | null`. The user's role is always fetched fresh from the database
|
||||
* so that role changes (e.g. demotion) take effect immediately.
|
||||
*/
|
||||
export async function auth(req?: NextRequest): Promise<Session | null> {
|
||||
const hdrs = req
|
||||
? req.headers
|
||||
: (await import("next/headers")).headers();
|
||||
|
||||
// headers() in Next.js 15+ returns a Promise
|
||||
const resolvedHeaders = hdrs instanceof Promise ? await hdrs : hdrs;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let betterAuthSession: any = null;
|
||||
|
||||
try {
|
||||
betterAuthSession = await getAuth().api.getSession({
|
||||
headers: resolvedHeaders,
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
interface User {
|
||||
if (!betterAuthSession?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baUser = betterAuthSession.user as {
|
||||
id: string | number;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
image?: string | null;
|
||||
role?: string;
|
||||
provider?: string;
|
||||
}
|
||||
}
|
||||
status?: string;
|
||||
avatarUrl?: string | null;
|
||||
subject?: string;
|
||||
};
|
||||
const userId = typeof baUser.id === "string" ? Number(baUser.id) : baUser.id;
|
||||
|
||||
// Credentials provider that checks against hashed passwords in the database
|
||||
function createCredentialsProvider() {
|
||||
return Credentials({
|
||||
id: "credentials",
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
username: { label: "Username", type: "text" },
|
||||
password: { label: "Password", type: "password" }
|
||||
},
|
||||
async authorize(credentials) {
|
||||
const username = credentials?.username ? String(credentials.username).trim() : "";
|
||||
const password = credentials?.password ? String(credentials.password) : "";
|
||||
|
||||
if (!username || !password) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up user in database by email (constructed from username)
|
||||
const email = `${username}@localhost`;
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => operators.eq(table.email, email)
|
||||
});
|
||||
|
||||
if (!user || user.status !== "active" || !user.passwordHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify password against hashed password in database
|
||||
const isValidPassword = bcrypt.compareSync(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
name: user.name ?? username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const credentialsProvider = createCredentialsProvider();
|
||||
|
||||
// Create OAuth providers based on configuration
|
||||
function createOAuthProviders(): OAuthConfig<Record<string, unknown>>[] {
|
||||
const providers: OAuthConfig<Record<string, unknown>>[] = [];
|
||||
|
||||
if (
|
||||
config.oauth.enabled &&
|
||||
config.oauth.clientId &&
|
||||
config.oauth.clientSecret
|
||||
) {
|
||||
const oauthProvider: OAuthConfig<Record<string, unknown>> = {
|
||||
id: "oauth2",
|
||||
name: config.oauth.providerName,
|
||||
type: "oidc",
|
||||
clientId: config.oauth.clientId,
|
||||
clientSecret: config.oauth.clientSecret,
|
||||
issuer: config.oauth.issuer ?? undefined,
|
||||
authorization: config.oauth.authorizationUrl ?? undefined,
|
||||
token: config.oauth.tokenUrl ?? undefined,
|
||||
userinfo: config.oauth.userinfoUrl ?? undefined,
|
||||
// PKCE is the default for OIDC; state is added as defence-in-depth
|
||||
checks: ["pkce", "state"],
|
||||
profile(profile) {
|
||||
const sub = typeof profile.sub === "string" ? profile.sub : undefined;
|
||||
const id = typeof profile.id === "string" ? profile.id : undefined;
|
||||
const name = typeof profile.name === "string" ? profile.name : undefined;
|
||||
const preferredUsername =
|
||||
typeof profile.preferred_username === "string" ? profile.preferred_username : undefined;
|
||||
const email = typeof profile.email === "string" ? profile.email : undefined;
|
||||
const picture = typeof profile.picture === "string" ? profile.picture : null;
|
||||
const avatarUrl = typeof profile.avatar_url === "string" ? profile.avatar_url : null;
|
||||
|
||||
return {
|
||||
id: sub ?? id,
|
||||
name: name ?? preferredUsername ?? email,
|
||||
email,
|
||||
image: picture ?? avatarUrl,
|
||||
};
|
||||
},
|
||||
};
|
||||
providers.push(oauthProvider);
|
||||
// Always fetch current role/status from database to reflect changes immediately
|
||||
const currentUser = await getUserById(userId);
|
||||
if (!currentUser || currentUser.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providers: [credentialsProvider, ...createOAuthProviders()],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
},
|
||||
pages: {
|
||||
signIn: "/login",
|
||||
},
|
||||
callbacks: {
|
||||
async signIn({ user, account }) {
|
||||
// Credentials provider - handled by authorize function
|
||||
if (account?.provider === "credentials") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OAuth provider sign-in
|
||||
if (!account || !user.email) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this is an OAuth linking attempt by checking the database
|
||||
const { pendingOAuthLinks } = await import("./db/schema");
|
||||
const { eq } = await import("drizzle-orm");
|
||||
const { nowIso } = await import("./db");
|
||||
|
||||
// Find ALL non-expired pending links for this provider
|
||||
const allPendingLinks = await db.query.pendingOAuthLinks.findMany({
|
||||
where: (table, operators) =>
|
||||
operators.and(
|
||||
operators.eq(table.provider, account.provider),
|
||||
operators.gt(table.expiresAt, nowIso())
|
||||
)
|
||||
});
|
||||
|
||||
// Security: Match by userId to prevent race condition where User B could
|
||||
// overwrite User A's pending link. We verify by checking which user exists.
|
||||
let pendingLink = null;
|
||||
if (allPendingLinks.length === 1) {
|
||||
// Common case: only one user is linking this provider right now
|
||||
pendingLink = allPendingLinks[0];
|
||||
} else if (allPendingLinks.length > 1) {
|
||||
// Race condition detected: multiple users linking same provider
|
||||
// This shouldn't happen with unique index, but handle gracefully
|
||||
// Find the user whose email matches their stored email
|
||||
for (const link of allPendingLinks) {
|
||||
const existingUser = await getUserById(link.userId);
|
||||
if (existingUser && existingUser.email === link.userEmail) {
|
||||
pendingLink = link;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingLink) {
|
||||
try {
|
||||
const userId = pendingLink.userId;
|
||||
const existingUser = await getUserById(userId);
|
||||
|
||||
if (existingUser) {
|
||||
// Security: Validate OAuth email matches the authenticated user's stored email
|
||||
// This prevents users from linking arbitrary OAuth accounts to their credentials account
|
||||
if (user.email && (
|
||||
existingUser.email !== pendingLink.userEmail ||
|
||||
user.email.toLowerCase() !== pendingLink.userEmail.toLowerCase()
|
||||
)) {
|
||||
console.error(`OAuth linking rejected: user email mismatch. Expected ${pendingLink.userEmail}, got ${existingUser.email} (OAuth provider returned ${user.email})`);
|
||||
|
||||
// Clean up the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log for security event
|
||||
await createAuditEvent({
|
||||
userId: existingUser.id,
|
||||
action: "oauth_link_rejected",
|
||||
entityType: "user",
|
||||
entityId: existingUser.id,
|
||||
summary: `OAuth linking rejected: email mismatch`,
|
||||
data: JSON.stringify({
|
||||
provider: account.provider,
|
||||
expectedEmail: pendingLink.userEmail,
|
||||
actualEmail: existingUser.email
|
||||
})
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// User is already authenticated - auto-link
|
||||
const linked = await linkOAuthAuthenticated(
|
||||
userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
// Reload user from database to get updated data
|
||||
const updatedUser = await getUserById(userId);
|
||||
|
||||
if (updatedUser) {
|
||||
user.id = updatedUser.id.toString();
|
||||
user.role = updatedUser.role;
|
||||
user.provider = updatedUser.provider;
|
||||
user.email = updatedUser.email;
|
||||
user.name = updatedUser.name;
|
||||
|
||||
// Delete the pending link
|
||||
await db.delete(pendingOAuthLinks).where(eq(pendingOAuthLinks.id, pendingLink.id));
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: updatedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: updatedUser.id,
|
||||
summary: `OAuth account linked via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error processing pending link:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(
|
||||
account.provider,
|
||||
account.providerAccountId
|
||||
);
|
||||
|
||||
if (existingOAuthUser) {
|
||||
// Existing OAuth user - update user object and allow sign-in
|
||||
user.id = existingOAuthUser.id.toString();
|
||||
user.role = existingOAuthUser.role;
|
||||
user.provider = existingOAuthUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: existingOAuthUser.id,
|
||||
action: "oauth_signin",
|
||||
entityType: "user",
|
||||
entityId: existingOAuthUser.id,
|
||||
summary: `${existingOAuthUser.name || existingOAuthUser.email || "User"} signed in via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine linking strategy
|
||||
const decision = await decideLinkingStrategy(
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
if (decision.action === "auto_link" && decision.userId) {
|
||||
// Auto-link OAuth to existing account without password
|
||||
const linked = await autoLinkOAuth(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.image
|
||||
);
|
||||
|
||||
if (linked) {
|
||||
const linkedUser = await getUserById(decision.userId);
|
||||
if (linkedUser) {
|
||||
user.id = linkedUser.id.toString();
|
||||
user.role = linkedUser.role;
|
||||
user.provider = linkedUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: linkedUser.id,
|
||||
action: "account_linked",
|
||||
entityType: "user",
|
||||
entityId: linkedUser.id,
|
||||
summary: `OAuth account auto-linked via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (decision.action === "require_manual_link" && decision.userId) {
|
||||
// Email collision - require manual linking with password verification
|
||||
const linkingToken = await createLinkingToken(
|
||||
decision.userId,
|
||||
account.provider,
|
||||
account.providerAccountId,
|
||||
user.email
|
||||
);
|
||||
|
||||
const linkingId = await storeLinkingToken(linkingToken);
|
||||
|
||||
// Redirect to link-account page with opaque ID (not the JWT)
|
||||
throw new Error(`LINKING_REQUIRED:${linkingId}`);
|
||||
}
|
||||
|
||||
// New OAuth user - create account
|
||||
const newUser = await createUser({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
provider: account.provider,
|
||||
subject: account.providerAccountId,
|
||||
avatar_url: user.image
|
||||
});
|
||||
|
||||
user.id = newUser.id.toString();
|
||||
user.role = newUser.role;
|
||||
user.provider = newUser.provider;
|
||||
|
||||
// Audit log
|
||||
await createAuditEvent({
|
||||
userId: newUser.id,
|
||||
action: "oauth_signup",
|
||||
entityType: "user",
|
||||
entityId: newUser.id,
|
||||
summary: `New user ${user.name || user.email || ""} created via ${config.oauth.providerName}`,
|
||||
data: JSON.stringify({ provider: account.provider, email: user.email })
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
// LINKING_REQUIRED is expected flow — rethrow so NextAuth can redirect
|
||||
if (error instanceof Error && error.message.startsWith("LINKING_REQUIRED:")) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.error("OAuth sign-in error:", error);
|
||||
|
||||
// Audit log for failed OAuth attempts
|
||||
try {
|
||||
await createAuditEvent({
|
||||
userId: null,
|
||||
action: "oauth_signin_failed",
|
||||
entityType: "user",
|
||||
entityId: null,
|
||||
summary: `OAuth sign-in failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
data: JSON.stringify({
|
||||
provider: account?.provider,
|
||||
email: user?.email,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
});
|
||||
} catch (auditError) {
|
||||
console.error("Failed to create audit log for OAuth error:", auditError);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: String(currentUser.id),
|
||||
email: currentUser.email,
|
||||
name: currentUser.name,
|
||||
role: currentUser.role,
|
||||
provider: currentUser.provider || baUser.provider,
|
||||
image: currentUser.avatarUrl ?? (baUser.avatarUrl as string | null | undefined) ?? null,
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
// On sign in, add user info to token
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.email = user.email;
|
||||
token.role = user.role ?? "user";
|
||||
token.provider = account?.provider ?? user.provider ?? "credentials";
|
||||
token.image = user.image;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// Add user info from token to session
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.provider = token.provider as string;
|
||||
|
||||
// Always fetch current role from database to reflect
|
||||
// role changes (e.g. demotion) without waiting for JWT expiry
|
||||
const userId = Number(token.id);
|
||||
const currentUser = await getUserById(userId);
|
||||
if (currentUser) {
|
||||
session.user.role = currentUser.role;
|
||||
session.user.image = currentUser.avatar_url ?? (token.image as string | null | undefined);
|
||||
} else {
|
||||
// User deleted from DB — deny access by clearing session
|
||||
session.user.role = token.role as string;
|
||||
session.user.image = token.image as string | null | undefined;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
secret: config.sessionSecret,
|
||||
// Only trust Host header when explicitly opted in or when NEXTAUTH_URL
|
||||
// is set (operator has declared the canonical URL, so Host validation is moot).
|
||||
trustHost: !!process.env.NEXTAUTH_TRUST_HOST || !!process.env.NEXTAUTH_URL,
|
||||
basePath: "/api/auth",
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper function to get the current session on the server.
|
||||
*/
|
||||
export async function getSession() {
|
||||
return await auth();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to require authentication, throwing if not authenticated.
|
||||
* Alias for auth() — get the current session on the server.
|
||||
*/
|
||||
export async function requireUser() {
|
||||
export async function getSession(): Promise<Session | null> {
|
||||
return auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication. Redirects to /login if not authenticated.
|
||||
*/
|
||||
export async function requireUser(): Promise<Session> {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
const { redirect } = await import("next/navigation");
|
||||
@@ -435,7 +96,10 @@ export async function requireUser() {
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
/**
|
||||
* Require admin privileges. Throws if not authenticated or not admin.
|
||||
*/
|
||||
export async function requireAdmin(): Promise<Session> {
|
||||
const session = await requireUser();
|
||||
if (session.user.role !== "admin") {
|
||||
throw new Error("Administrator privileges required");
|
||||
|
||||
@@ -21,10 +21,10 @@ export function normalizeFingerprint(fp: string): string {
|
||||
* Defined here to avoid importing from models (which pulls in db.ts).
|
||||
*/
|
||||
export type MtlsAccessRuleLike = {
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
pathPattern: string;
|
||||
allowedRoleIds: number[];
|
||||
allowedCertIds: number[];
|
||||
denyAll: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -177,14 +177,14 @@ export function resolveAllowedFingerprints(
|
||||
): Set<string> {
|
||||
const allowed = new Set<string>();
|
||||
|
||||
for (const roleId of rule.allowed_role_ids) {
|
||||
for (const roleId of rule.allowedRoleIds) {
|
||||
const fps = roleFingerprintMap.get(roleId);
|
||||
if (fps) {
|
||||
for (const fp of fps) allowed.add(fp);
|
||||
}
|
||||
}
|
||||
|
||||
for (const certId of rule.allowed_cert_ids) {
|
||||
for (const certId of rule.allowedCertIds) {
|
||||
const fp = certFingerprintMap.get(certId);
|
||||
if (fp) allowed.add(fp);
|
||||
}
|
||||
@@ -229,10 +229,10 @@ export function buildMtlsRbacSubroutes(
|
||||
|
||||
// Rules are already sorted by priority desc, path asc
|
||||
for (const rule of accessRules) {
|
||||
if (rule.deny_all) {
|
||||
if (rule.denyAll) {
|
||||
// Explicit deny: any request matching this path gets 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
@@ -248,7 +248,7 @@ export function buildMtlsRbacSubroutes(
|
||||
if (allowedFps.size === 0) {
|
||||
// Rule exists but no certs match → deny all for this path
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
@@ -262,14 +262,14 @@ export function buildMtlsRbacSubroutes(
|
||||
// Allow route: path + fingerprint CEL match
|
||||
const celExpr = buildFingerprintCelExpression(allowedFps);
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern], expression: celExpr }],
|
||||
match: [{ path: [rule.pathPattern], expression: celExpr }],
|
||||
handle: [...baseHandlers, reverseProxyHandler],
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
// Deny route: path matches but fingerprint didn't → 403
|
||||
subroutes.push({
|
||||
match: [{ path: [rule.path_pattern] }],
|
||||
match: [{ path: [rule.pathPattern] }],
|
||||
handle: [{
|
||||
handler: "static_response",
|
||||
status_code: "403",
|
||||
|
||||
130
src/lib/caddy.ts
130
src/lib/caddy.ts
@@ -82,14 +82,14 @@ type ProxyHostRow = {
|
||||
name: string;
|
||||
domains: string;
|
||||
upstreams: string;
|
||||
certificate_id: number | null;
|
||||
access_list_id: number | null;
|
||||
ssl_forced: number;
|
||||
hsts_enabled: number;
|
||||
hsts_subdomains: number;
|
||||
allow_websocket: number;
|
||||
preserve_host_header: number;
|
||||
skip_https_hostname_validation: number;
|
||||
certificateId: number | null;
|
||||
accessListId: number | null;
|
||||
sslForced: number;
|
||||
hstsEnabled: number;
|
||||
hstsSubdomains: number;
|
||||
allowWebsocket: number;
|
||||
preserveHostHeader: number;
|
||||
skipHttpsHostnameValidation: number;
|
||||
meta: string | null;
|
||||
enabled: number;
|
||||
};
|
||||
@@ -216,20 +216,20 @@ type LoadBalancerRouteConfig = {
|
||||
};
|
||||
|
||||
type AccessListEntryRow = {
|
||||
access_list_id: number;
|
||||
accessListId: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
passwordHash: string;
|
||||
};
|
||||
|
||||
type CertificateRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
domain_names: string;
|
||||
certificate_pem: string | null;
|
||||
private_key_pem: string | null;
|
||||
auto_renew: number;
|
||||
provider_options: string | null;
|
||||
domainNames: string;
|
||||
certificatePem: string | null;
|
||||
privateKeyPem: string | null;
|
||||
autoRenew: number;
|
||||
providerOptions: string | null;
|
||||
};
|
||||
|
||||
type CaddyHttpRoute = Record<string, unknown>;
|
||||
@@ -507,15 +507,15 @@ function collectCertificateUsage(rows: ProxyHostRow[], certificates: Map<number,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle auto-managed certificates (certificate_id is null)
|
||||
if (!row.certificate_id) {
|
||||
// Handle auto-managed certificates (certificateId is null)
|
||||
if (!row.certificateId) {
|
||||
for (const domain of filteredDomains) {
|
||||
autoManagedDomains.add(domain);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const cert = certificates.get(row.certificate_id);
|
||||
const cert = certificates.get(row.certificateId);
|
||||
if (!cert) {
|
||||
continue;
|
||||
}
|
||||
@@ -681,9 +681,9 @@ async function buildProxyRoutes(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow hosts with certificate_id = null (Caddy Auto) or with valid certificate IDs
|
||||
const isAutoManaged = !row.certificate_id;
|
||||
const hasValidCertificate = row.certificate_id && tlsReadyCertificates.has(row.certificate_id);
|
||||
// Allow hosts with certificateId = null (Caddy Auto) or with valid certificate IDs
|
||||
const isAutoManaged = !row.certificateId;
|
||||
const hasValidCertificate = row.certificateId && tlsReadyCertificates.has(row.certificateId);
|
||||
|
||||
if (!isAutoManaged && !hasValidCertificate) {
|
||||
continue;
|
||||
@@ -720,11 +720,11 @@ async function buildProxyRoutes(
|
||||
meta.waf
|
||||
);
|
||||
if (effectiveWaf?.enabled && effectiveWaf.mode !== 'Off') {
|
||||
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allow_websocket)));
|
||||
handlers.unshift(buildWafHandler(effectiveWaf, Boolean(row.allowWebsocket)));
|
||||
}
|
||||
|
||||
if (row.hsts_enabled) {
|
||||
const value = row.hsts_subdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
||||
if (row.hstsEnabled) {
|
||||
const value = row.hstsSubdomains ? "max-age=63072000; includeSubDomains" : "max-age=63072000";
|
||||
handlers.push({
|
||||
handler: "headers",
|
||||
response: {
|
||||
@@ -735,7 +735,7 @@ async function buildProxyRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (row.ssl_forced) {
|
||||
if (row.sslForced) {
|
||||
for (const domainGroup of domainGroups) {
|
||||
hostRoutes.push({
|
||||
match: [
|
||||
@@ -774,8 +774,8 @@ async function buildProxyRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
if (row.access_list_id) {
|
||||
const accounts = accessAccounts.get(row.access_list_id) ?? [];
|
||||
if (row.accessListId) {
|
||||
const accounts = accessAccounts.get(row.accessListId) ?? [];
|
||||
if (accounts.length > 0) {
|
||||
handlers.push({
|
||||
handler: "authentication",
|
||||
@@ -783,7 +783,7 @@ async function buildProxyRoutes(
|
||||
http_basic: {
|
||||
accounts: accounts.map((entry) => ({
|
||||
username: entry.username,
|
||||
password: entry.password_hash
|
||||
password: entry.passwordHash
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -856,7 +856,7 @@ async function buildProxyRoutes(
|
||||
};
|
||||
}
|
||||
|
||||
if (row.preserve_host_header) {
|
||||
if (row.preserveHostHeader) {
|
||||
reverseProxyHandler.headers = {
|
||||
request: {
|
||||
set: {
|
||||
@@ -868,7 +868,7 @@ async function buildProxyRoutes(
|
||||
|
||||
// Configure TLS transport for HTTPS upstreams
|
||||
if (resolvedUpstreams.hasHttpsUpstream) {
|
||||
const tlsTransport: Record<string, unknown> = row.skip_https_hostname_validation
|
||||
const tlsTransport: Record<string, unknown> = row.skipHttpsHostnameValidation
|
||||
? {
|
||||
insecure_skip_verify: true
|
||||
}
|
||||
@@ -1068,8 +1068,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1104,8 +1104,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1255,8 +1255,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1286,8 +1286,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1318,8 +1318,8 @@ async function buildProxyRoutes(
|
||||
for (const rule of locationRules) {
|
||||
const { safePath, reverseProxyHandler: locationProxy } = buildLocationReverseProxy(
|
||||
rule,
|
||||
Boolean(row.skip_https_hostname_validation),
|
||||
Boolean(row.preserve_host_header)
|
||||
Boolean(row.skipHttpsHostnameValidation),
|
||||
Boolean(row.preserveHostHeader)
|
||||
);
|
||||
if (!safePath) continue;
|
||||
hostRoutes.push({
|
||||
@@ -1410,7 +1410,7 @@ function buildTlsConnectionPolicies(
|
||||
}
|
||||
};
|
||||
|
||||
// Add policy for auto-managed domains (certificate_id = null)
|
||||
// Add policy for auto-managed domains (certificateId = null)
|
||||
if (autoManagedDomains.size > 0) {
|
||||
const domains = Array.from(autoManagedDomains);
|
||||
// Split first so mTLS domains always get their own policy, regardless of auth result.
|
||||
@@ -1432,14 +1432,14 @@ function buildTlsConnectionPolicies(
|
||||
}
|
||||
|
||||
if (entry.certificate.type === "imported") {
|
||||
if (!entry.certificate.certificate_pem || !entry.certificate.private_key_pem) {
|
||||
if (!entry.certificate.certificatePem || !entry.certificate.privateKeyPem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Collect PEMs for tls.certificates.load_pem (inline, no shared filesystem needed)
|
||||
importedCertPems.push({
|
||||
certificate: entry.certificate.certificate_pem.trim(),
|
||||
key: entry.certificate.private_key_pem.trim()
|
||||
certificate: entry.certificate.certificatePem.trim(),
|
||||
key: entry.certificate.privateKeyPem.trim()
|
||||
});
|
||||
|
||||
const mTlsDomains = domains.filter(d => mTlsDomainMap.has(d));
|
||||
@@ -1488,7 +1488,7 @@ async function buildTlsAutomation(
|
||||
options: { acmeEmail?: string; dnsSettings?: DnsSettings | null }
|
||||
) {
|
||||
const managedEntries = Array.from(usage.values()).filter(
|
||||
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.auto_renew)
|
||||
(entry) => entry.certificate.type === "managed" && Boolean(entry.certificate.autoRenew)
|
||||
);
|
||||
|
||||
const hasAutoManagedDomains = autoManagedDomains.size > 0;
|
||||
@@ -1517,7 +1517,7 @@ async function buildTlsAutomation(
|
||||
const managedCertificateIds = new Set<number>();
|
||||
const policies: Record<string, unknown>[] = [];
|
||||
|
||||
// Add policy for auto-managed domains (certificate_id = null)
|
||||
// Add policy for auto-managed domains (certificateId = null)
|
||||
if (hasAutoManagedDomains) {
|
||||
for (const subjects of groupHostPatternsByPriority(Array.from(autoManagedDomains))) {
|
||||
const issuer: Record<string, unknown> = {
|
||||
@@ -1894,14 +1894,14 @@ async function buildCaddyDocument() {
|
||||
name: h.name,
|
||||
domains: h.domains,
|
||||
upstreams: h.upstreams,
|
||||
certificate_id: h.certificateId,
|
||||
access_list_id: h.accessListId,
|
||||
ssl_forced: h.sslForced ? 1 : 0,
|
||||
hsts_enabled: h.hstsEnabled ? 1 : 0,
|
||||
hsts_subdomains: h.hstsSubdomains ? 1 : 0,
|
||||
allow_websocket: h.allowWebsocket ? 1 : 0,
|
||||
preserve_host_header: h.preserveHostHeader ? 1 : 0,
|
||||
skip_https_hostname_validation: h.skipHttpsHostnameValidation ? 1 : 0,
|
||||
certificateId: h.certificateId,
|
||||
accessListId: h.accessListId,
|
||||
sslForced: h.sslForced ? 1 : 0,
|
||||
hstsEnabled: h.hstsEnabled ? 1 : 0,
|
||||
hstsSubdomains: h.hstsSubdomains ? 1 : 0,
|
||||
allowWebsocket: h.allowWebsocket ? 1 : 0,
|
||||
preserveHostHeader: h.preserveHostHeader ? 1 : 0,
|
||||
skipHttpsHostnameValidation: h.skipHttpsHostnameValidation ? 1 : 0,
|
||||
meta: h.meta,
|
||||
enabled: h.enabled ? 1 : 0
|
||||
}));
|
||||
@@ -1910,17 +1910,17 @@ async function buildCaddyDocument() {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
type: c.type as "managed" | "imported",
|
||||
domain_names: c.domainNames,
|
||||
certificate_pem: c.certificatePem,
|
||||
private_key_pem: c.privateKeyPem,
|
||||
auto_renew: c.autoRenew ? 1 : 0,
|
||||
provider_options: c.providerOptions
|
||||
domainNames: c.domainNames,
|
||||
certificatePem: c.certificatePem,
|
||||
privateKeyPem: c.privateKeyPem,
|
||||
autoRenew: c.autoRenew ? 1 : 0,
|
||||
providerOptions: c.providerOptions
|
||||
}));
|
||||
|
||||
const accessListEntryRows: AccessListEntryRow[] = accessListEntryRecords.map((entry) => ({
|
||||
access_list_id: entry.accessListId,
|
||||
accessListId: entry.accessListId,
|
||||
username: entry.username,
|
||||
password_hash: entry.passwordHash
|
||||
passwordHash: entry.passwordHash
|
||||
}));
|
||||
|
||||
const certificateMap = new Map(certRowsMapped.map((cert) => [cert.id, cert]));
|
||||
@@ -1933,10 +1933,10 @@ async function buildCaddyDocument() {
|
||||
}, new Map());
|
||||
const cAsWithAnyIssuedCerts = new Set(allIssuedCaCertIds.map(r => r.caCertificateId));
|
||||
const accessMap = accessListEntryRows.reduce<Map<number, AccessListEntryRow[]>>((map, entry) => {
|
||||
if (!map.has(entry.access_list_id)) {
|
||||
map.set(entry.access_list_id, []);
|
||||
if (!map.has(entry.accessListId)) {
|
||||
map.set(entry.accessListId, []);
|
||||
}
|
||||
map.get(entry.access_list_id)!.push(entry);
|
||||
map.get(entry.accessListId)!.push(entry);
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
@@ -2187,7 +2187,7 @@ export async function applyCaddyConfig() {
|
||||
const document = await buildCaddyDocument();
|
||||
const payload = JSON.stringify(document);
|
||||
const hash = crypto.createHash("sha256").update(payload).digest("hex");
|
||||
setSetting("caddy_config_hash", { hash, updated_at: nowIso() });
|
||||
setSetting("caddy_config_hash", { hash, updatedAt: nowIso() });
|
||||
|
||||
try {
|
||||
const response = await caddyRequest(`${config.caddyApiUrl}/load`, "POST", payload);
|
||||
|
||||
142
src/lib/db.ts
142
src/lib/db.ts
@@ -1,6 +1,8 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { eq, ne, and, isNull } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve as resolvePath } from "node:path";
|
||||
import * as schema from "./db/schema";
|
||||
@@ -58,7 +60,7 @@ function ensureDirectoryFor(pathname: string) {
|
||||
|
||||
const globalForDrizzle = globalThis as GlobalForDrizzle;
|
||||
|
||||
const sqlite =
|
||||
export const sqlite =
|
||||
globalForDrizzle.__SQLITE_CLIENT__ ??
|
||||
(() => {
|
||||
ensureDirectoryFor(sqlitePath);
|
||||
@@ -70,7 +72,7 @@ if (process.env.NODE_ENV !== "production") {
|
||||
}
|
||||
|
||||
export const db =
|
||||
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema, casing: "snake_case" });
|
||||
globalForDrizzle.__DRIZZLE_DB__ ?? drizzle(sqlite, { schema });
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForDrizzle.__DRIZZLE_DB__ = db;
|
||||
@@ -121,6 +123,142 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration: populate `accounts` table from existing users' provider/subject fields.
|
||||
* Also creates credential accounts for password users and syncs env OAuth providers.
|
||||
* Idempotent — skips if already run (checked via settings flag).
|
||||
*/
|
||||
function runBetterAuthDataMigration() {
|
||||
if (sqlitePath === ":memory:") return;
|
||||
|
||||
const { settings, users, accounts } = schema;
|
||||
|
||||
const flag = db.select().from(settings).where(eq(settings.key, "better_auth_migrated")).get();
|
||||
if (flag) return;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Migrate OAuth users: create account rows from users.provider/subject
|
||||
const oauthUsers = db.select().from(users).where(ne(users.provider, "credentials")).all();
|
||||
for (const user of oauthUsers) {
|
||||
if (!user.provider || !user.subject) continue;
|
||||
const existing = db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, user.id), eq(accounts.providerId, user.provider), eq(accounts.accountId, user.subject))
|
||||
).get();
|
||||
if (!existing) {
|
||||
db.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.subject,
|
||||
providerId: user.provider,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate credentials users: create credential account rows
|
||||
const credentialUsers = db.select().from(users).where(eq(users.provider, "credentials")).all();
|
||||
for (const user of credentialUsers) {
|
||||
const existing = db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, user.id), eq(accounts.providerId, "credential"))
|
||||
).get();
|
||||
if (!existing) {
|
||||
db.insert(accounts).values({
|
||||
userId: user.id,
|
||||
accountId: user.id.toString(),
|
||||
providerId: "credential",
|
||||
password: user.passwordHash,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
}).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Populate username field for all users (derived from email prefix)
|
||||
const usersWithoutUsername = db.select().from(users).where(isNull(users.username)).all();
|
||||
for (const user of usersWithoutUsername) {
|
||||
const usernameFromEmail = user.email.split("@")[0] || user.email;
|
||||
db.update(users).set({
|
||||
username: usernameFromEmail.toLowerCase(),
|
||||
displayUsername: usernameFromEmail,
|
||||
}).where(eq(users.id, user.id)).run();
|
||||
}
|
||||
|
||||
db.insert(settings).values({ key: "better_auth_migrated", value: "true", updatedAt: now }).run();
|
||||
console.log("Better Auth data migration complete: populated accounts table");
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync OAUTH_* env vars into the oauthProviders table (synchronous).
|
||||
* Uses raw Drizzle queries since this runs at module load time.
|
||||
*/
|
||||
function runEnvProviderSync() {
|
||||
if (sqlitePath === ":memory:") return;
|
||||
|
||||
// Lazy import to avoid circular dependency at module load
|
||||
let config: { oauth: { enabled: boolean; providerName: string; clientId: string | null; clientSecret: string | null; issuer: string | null; authorizationUrl: string | null; tokenUrl: string | null; userinfoUrl: string | null; allowAutoLinking: boolean } };
|
||||
try {
|
||||
config = require("./config").config;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.oauth.enabled || !config.oauth.clientId || !config.oauth.clientSecret) return;
|
||||
|
||||
const { oauthProviders } = schema;
|
||||
let encryptSecret: (v: string) => string;
|
||||
try {
|
||||
encryptSecret = require("./secret").encryptSecret;
|
||||
} catch {
|
||||
encryptSecret = (v) => v;
|
||||
}
|
||||
|
||||
const name = config.oauth.providerName;
|
||||
// Use a slug-based ID so the OAuth callback URL is predictable
|
||||
const providerId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "oauth";
|
||||
const existing = db.select().from(oauthProviders).where(eq(oauthProviders.name, name)).get();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
if (existing && existing.source === "env") {
|
||||
db.update(oauthProviders).set({
|
||||
clientId: encryptSecret(config.oauth.clientId),
|
||||
clientSecret: encryptSecret(config.oauth.clientSecret),
|
||||
issuer: config.oauth.issuer ?? null,
|
||||
authorizationUrl: config.oauth.authorizationUrl ?? null,
|
||||
tokenUrl: config.oauth.tokenUrl ?? null,
|
||||
userinfoUrl: config.oauth.userinfoUrl ?? null,
|
||||
autoLink: config.oauth.allowAutoLinking,
|
||||
updatedAt: now,
|
||||
}).where(eq(oauthProviders.id, existing.id)).run();
|
||||
} else if (!existing) {
|
||||
db.insert(oauthProviders).values({
|
||||
id: providerId,
|
||||
name,
|
||||
type: "oidc",
|
||||
clientId: encryptSecret(config.oauth.clientId),
|
||||
clientSecret: encryptSecret(config.oauth.clientSecret),
|
||||
issuer: config.oauth.issuer ?? null,
|
||||
authorizationUrl: config.oauth.authorizationUrl ?? null,
|
||||
tokenUrl: config.oauth.tokenUrl ?? null,
|
||||
userinfoUrl: config.oauth.userinfoUrl ?? null,
|
||||
scopes: "openid email profile",
|
||||
autoLink: config.oauth.allowAutoLinking,
|
||||
enabled: true,
|
||||
source: "env",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run();
|
||||
console.log(`Synced OAuth provider from env: ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
runBetterAuthDataMigration();
|
||||
runEnvProviderSync();
|
||||
} catch (error) {
|
||||
console.warn("Better Auth data migration warning:", error);
|
||||
}
|
||||
|
||||
export { schema };
|
||||
export default db;
|
||||
|
||||
|
||||
@@ -6,34 +6,102 @@ export const users = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
email: text("email").notNull(),
|
||||
name: text("name"),
|
||||
passwordHash: text("password_hash"),
|
||||
passwordHash: text("passwordHash"),
|
||||
role: text("role").notNull().default("user"),
|
||||
provider: text("provider").notNull(),
|
||||
subject: text("subject").notNull(),
|
||||
avatarUrl: text("avatar_url"),
|
||||
provider: text("provider"),
|
||||
subject: text("subject"),
|
||||
avatarUrl: text("avatarUrl"),
|
||||
status: text("status").notNull().default("active"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
username: text("username"),
|
||||
displayUsername: text("displayUsername"),
|
||||
emailVerified: integer("emailVerified", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email),
|
||||
providerSubjectIdx: uniqueIndex("users_provider_subject_idx").on(table.provider, table.subject)
|
||||
emailUnique: uniqueIndex("users_email_unique").on(table.email)
|
||||
})
|
||||
);
|
||||
|
||||
// Auth tables use camelCase DB columns to match Better Auth's Kysely adapter.
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
token: text("token").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
ipAddress: text("ipAddress"),
|
||||
userAgent: text("userAgent"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token)
|
||||
tokenUnique: uniqueIndex("sessions_token_unique").on(table.token),
|
||||
userIdx: index("sessions_user_idx").on(table.userId)
|
||||
})
|
||||
);
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
"accounts",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
accountId: text("accountId").notNull(),
|
||||
providerId: text("providerId").notNull(),
|
||||
accessToken: text("accessToken"),
|
||||
refreshToken: text("refreshToken"),
|
||||
idToken: text("idToken"),
|
||||
accessTokenExpiresAt: text("accessTokenExpiresAt"),
|
||||
refreshTokenExpiresAt: text("refreshTokenExpiresAt"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
providerAccountIdx: uniqueIndex("accounts_provider_account_idx").on(table.providerId, table.accountId),
|
||||
userIdx: index("accounts_user_idx").on(table.userId)
|
||||
})
|
||||
);
|
||||
|
||||
export const verifications = sqliteTable(
|
||||
"verifications",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
createdAt: text("createdAt"),
|
||||
updatedAt: text("updatedAt")
|
||||
}
|
||||
);
|
||||
|
||||
export const oauthProviders = sqliteTable(
|
||||
"oauth_providers",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull().default("oidc"),
|
||||
clientId: text("clientId").notNull(),
|
||||
clientSecret: text("clientSecret").notNull(),
|
||||
issuer: text("issuer"),
|
||||
authorizationUrl: text("authorizationUrl"),
|
||||
tokenUrl: text("tokenUrl"),
|
||||
userinfoUrl: text("userinfoUrl"),
|
||||
scopes: text("scopes").notNull().default("openid email profile"),
|
||||
autoLink: integer("autoLink", { mode: "boolean" }).notNull().default(false),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
source: text("source").notNull().default("ui"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("oauth_providers_name_unique").on(table.name)
|
||||
})
|
||||
);
|
||||
|
||||
@@ -42,10 +110,10 @@ export const oauthStates = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
state: text("state").notNull(),
|
||||
codeVerifier: text("code_verifier").notNull(),
|
||||
redirectTo: text("redirect_to"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
codeVerifier: text("codeVerifier").notNull(),
|
||||
redirectTo: text("redirectTo"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
stateUnique: uniqueIndex("oauth_state_unique").on(table.state)
|
||||
@@ -54,11 +122,11 @@ export const oauthStates = sqliteTable(
|
||||
|
||||
export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
userId: integer("userId").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
provider: text("provider", { length: 50 }).notNull(),
|
||||
userEmail: text("user_email").notNull(), // Email of the user who initiated linking
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
userEmail: text("userEmail").notNull(), // Email of the user who initiated linking
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
}, (table) => ({
|
||||
// Ensure only one pending link per user per provider (prevents race conditions)
|
||||
userProviderUnique: uniqueIndex("pending_oauth_user_provider_unique").on(table.userId, table.provider)
|
||||
@@ -67,7 +135,7 @@ export const pendingOAuthLinks = sqliteTable("pending_oauth_links", {
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const instances = sqliteTable(
|
||||
@@ -75,13 +143,13 @@ export const instances = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
apiToken: text("api_token").notNull(),
|
||||
baseUrl: text("baseUrl").notNull(),
|
||||
apiToken: text("apiToken").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
lastSyncAt: text("last_sync_at"),
|
||||
lastSyncError: text("last_sync_error"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
lastSyncAt: text("lastSyncAt"),
|
||||
lastSyncError: text("lastSyncError"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
baseUrlUnique: uniqueIndex("instances_base_url_unique").on(table.baseUrl)
|
||||
@@ -92,22 +160,22 @@ export const accessLists = sqliteTable("access_lists", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const accessListEntries = sqliteTable(
|
||||
"access_list_entries",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
accessListId: integer("access_list_id")
|
||||
accessListId: integer("accessListId")
|
||||
.references(() => accessLists.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
username: text("username").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
passwordHash: text("passwordHash").notNull(),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
accessListIdIdx: index("access_list_entries_list_idx").on(table.accessListId)
|
||||
@@ -118,43 +186,43 @@ export const certificates = sqliteTable("certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
domainNames: text("domain_names").notNull(),
|
||||
autoRenew: integer("auto_renew", { mode: "boolean" }).notNull().default(true),
|
||||
providerOptions: text("provider_options"),
|
||||
certificatePem: text("certificate_pem"),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
domainNames: text("domainNames").notNull(),
|
||||
autoRenew: integer("autoRenew", { mode: "boolean" }).notNull().default(true),
|
||||
providerOptions: text("providerOptions"),
|
||||
certificatePem: text("certificatePem"),
|
||||
privateKeyPem: text("privateKeyPem"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const caCertificates = sqliteTable("ca_certificates", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
privateKeyPem: text("private_key_pem"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
certificatePem: text("certificatePem").notNull(),
|
||||
privateKeyPem: text("privateKeyPem"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
});
|
||||
|
||||
export const issuedClientCertificates = sqliteTable(
|
||||
"issued_client_certificates",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
caCertificateId: integer("ca_certificate_id")
|
||||
caCertificateId: integer("caCertificateId")
|
||||
.references(() => caCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
commonName: text("common_name").notNull(),
|
||||
serialNumber: text("serial_number").notNull(),
|
||||
fingerprintSha256: text("fingerprint_sha256").notNull(),
|
||||
certificatePem: text("certificate_pem").notNull(),
|
||||
validFrom: text("valid_from").notNull(),
|
||||
validTo: text("valid_to").notNull(),
|
||||
revokedAt: text("revoked_at"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
commonName: text("commonName").notNull(),
|
||||
serialNumber: text("serialNumber").notNull(),
|
||||
fingerprintSha256: text("fingerprintSha256").notNull(),
|
||||
certificatePem: text("certificatePem").notNull(),
|
||||
validFrom: text("validFrom").notNull(),
|
||||
validTo: text("validTo").notNull(),
|
||||
revokedAt: text("revokedAt"),
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
caCertificateIdx: index("issued_client_certificates_ca_idx").on(table.caCertificateId),
|
||||
@@ -167,19 +235,19 @@ export const proxyHosts = sqliteTable("proxy_hosts", {
|
||||
name: text("name").notNull(),
|
||||
domains: text("domains").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
certificateId: integer("certificate_id").references(() => certificates.id, { onDelete: "set null" }),
|
||||
accessListId: integer("access_list_id").references(() => accessLists.id, { onDelete: "set null" }),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
sslForced: integer("ssl_forced", { mode: "boolean" }).notNull().default(true),
|
||||
hstsEnabled: integer("hsts_enabled", { mode: "boolean" }).notNull().default(true),
|
||||
hstsSubdomains: integer("hsts_subdomains", { mode: "boolean" }).notNull().default(false),
|
||||
allowWebsocket: integer("allow_websocket", { mode: "boolean" }).notNull().default(true),
|
||||
preserveHostHeader: integer("preserve_host_header", { mode: "boolean" }).notNull().default(true),
|
||||
certificateId: integer("certificateId").references(() => certificates.id, { onDelete: "set null" }),
|
||||
accessListId: integer("accessListId").references(() => accessLists.id, { onDelete: "set null" }),
|
||||
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
|
||||
sslForced: integer("sslForced", { mode: "boolean" }).notNull().default(true),
|
||||
hstsEnabled: integer("hstsEnabled", { mode: "boolean" }).notNull().default(true),
|
||||
hstsSubdomains: integer("hstsSubdomains", { mode: "boolean" }).notNull().default(false),
|
||||
allowWebsocket: integer("allowWebsocket", { mode: "boolean" }).notNull().default(true),
|
||||
preserveHostHeader: integer("preserveHostHeader", { mode: "boolean" }).notNull().default(true),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
skipHttpsHostnameValidation: integer("skip_https_hostname_validation", { mode: "boolean" })
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull(),
|
||||
skipHttpsHostnameValidation: integer("skipHttpsHostnameValidation", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false)
|
||||
});
|
||||
@@ -189,13 +257,13 @@ export const apiTokens = sqliteTable(
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
createdBy: integer("created_by")
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
createdBy: integer("createdBy")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
lastUsedAt: text("last_used_at"),
|
||||
expiresAt: text("expires_at")
|
||||
createdAt: text("createdAt").notNull(),
|
||||
lastUsedAt: text("lastUsedAt"),
|
||||
expiresAt: text("expiresAt")
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashUnique: uniqueIndex("api_tokens_token_hash_unique").on(table.tokenHash)
|
||||
@@ -204,20 +272,20 @@ export const apiTokens = sqliteTable(
|
||||
|
||||
export const auditEvents = sqliteTable("audit_events", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
userId: integer("userId").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: integer("entity_id"),
|
||||
entityType: text("entityType").notNull(),
|
||||
entityId: integer("entityId"),
|
||||
summary: text("summary"),
|
||||
data: text("data"),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
});
|
||||
|
||||
export const linkingTokens = sqliteTable("linking_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
expiresAt: text("expires_at").notNull()
|
||||
createdAt: text("createdAt").notNull(),
|
||||
expiresAt: text("expiresAt").notNull()
|
||||
});
|
||||
|
||||
// traffic_events and waf_events have been migrated to ClickHouse.
|
||||
@@ -241,9 +309,9 @@ export const mtlsRoles = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("mtls_roles_name_unique").on(table.name)
|
||||
@@ -254,13 +322,13 @@ export const mtlsCertificateRoles = sqliteTable(
|
||||
"mtls_certificate_roles",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
issuedClientCertificateId: integer("issued_client_certificate_id")
|
||||
issuedClientCertificateId: integer("issuedClientCertificateId")
|
||||
.references(() => issuedClientCertificates.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
mtlsRoleId: integer("mtls_role_id")
|
||||
mtlsRoleId: integer("mtlsRoleId")
|
||||
.references(() => mtlsRoles.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
certRoleUnique: uniqueIndex("mtls_cert_role_unique").on(
|
||||
@@ -275,18 +343,18 @@ export const mtlsAccessRules = sqliteTable(
|
||||
"mtls_access_rules",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
proxyHostId: integer("proxyHostId")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
pathPattern: text("path_pattern").notNull(),
|
||||
allowedRoleIds: text("allowed_role_ids").notNull().default("[]"),
|
||||
allowedCertIds: text("allowed_cert_ids").notNull().default("[]"),
|
||||
denyAll: integer("deny_all", { mode: "boolean" }).notNull().default(false),
|
||||
pathPattern: text("pathPattern").notNull(),
|
||||
allowedRoleIds: text("allowedRoleIds").notNull().default("[]"),
|
||||
allowedCertIds: text("allowedCertIds").notNull().default("[]"),
|
||||
denyAll: integer("denyAll", { mode: "boolean" }).notNull().default(false),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
proxyHostIdx: index("mtls_access_rules_proxy_host_idx").on(table.proxyHostId),
|
||||
@@ -305,9 +373,9 @@ export const groups = sqliteTable(
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull()
|
||||
createdBy: integer("createdBy").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
nameUnique: uniqueIndex("groups_name_unique").on(table.name)
|
||||
@@ -318,13 +386,13 @@ export const groupMembers = sqliteTable(
|
||||
"group_members",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
groupId: integer("group_id")
|
||||
groupId: integer("groupId")
|
||||
.references(() => groups.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
memberUnique: uniqueIndex("group_members_unique").on(table.groupId, table.userId),
|
||||
@@ -336,12 +404,12 @@ export const forwardAuthAccess = sqliteTable(
|
||||
"forward_auth_access",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
proxyHostId: integer("proxy_host_id")
|
||||
proxyHostId: integer("proxyHostId")
|
||||
.references(() => proxyHosts.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
userId: integer("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: integer("group_id").references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: text("created_at").notNull()
|
||||
userId: integer("userId").references(() => users.id, { onDelete: "cascade" }),
|
||||
groupId: integer("groupId").references(() => groups.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
hostIdx: index("faa_host_idx").on(table.proxyHostId),
|
||||
@@ -354,12 +422,12 @@ export const forwardAuthSessions = sqliteTable(
|
||||
"forward_auth_sessions",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: integer("user_id")
|
||||
userId: integer("userId")
|
||||
.references(() => users.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
createdAt: text("created_at").notNull()
|
||||
tokenHash: text("tokenHash").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
tokenHashUnique: uniqueIndex("fas_token_hash_unique").on(table.tokenHash),
|
||||
@@ -372,15 +440,15 @@ export const forwardAuthExchanges = sqliteTable(
|
||||
"forward_auth_exchanges",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
sessionId: integer("session_id")
|
||||
sessionId: integer("sessionId")
|
||||
.references(() => forwardAuthSessions.id, { onDelete: "cascade" })
|
||||
.notNull(),
|
||||
codeHash: text("code_hash").notNull(),
|
||||
sessionToken: text("session_token").notNull(), // raw session token (short-lived, single-use)
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
codeHash: text("codeHash").notNull(),
|
||||
sessionToken: text("sessionToken").notNull(), // raw session token (short-lived, single-use)
|
||||
redirectUri: text("redirectUri").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
used: integer("used", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
codeHashUnique: uniqueIndex("fae_code_hash_unique").on(table.codeHash)
|
||||
@@ -391,11 +459,11 @@ export const forwardAuthRedirectIntents = sqliteTable(
|
||||
"forward_auth_redirect_intents",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ridHash: text("rid_hash").notNull(),
|
||||
redirectUri: text("redirect_uri").notNull(),
|
||||
expiresAt: text("expires_at").notNull(),
|
||||
ridHash: text("ridHash").notNull(),
|
||||
redirectUri: text("redirectUri").notNull(),
|
||||
expiresAt: text("expiresAt").notNull(),
|
||||
consumed: integer("consumed", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at").notNull()
|
||||
createdAt: text("createdAt").notNull()
|
||||
},
|
||||
(table) => ({
|
||||
ridHashUnique: uniqueIndex("fari_rid_hash_unique").on(table.ridHash),
|
||||
@@ -409,16 +477,16 @@ export const l4ProxyHosts = sqliteTable("l4_proxy_hosts", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
protocol: text("protocol").notNull(),
|
||||
listenAddress: text("listen_address").notNull(),
|
||||
listenAddress: text("listenAddress").notNull(),
|
||||
upstreams: text("upstreams").notNull(),
|
||||
matcherType: text("matcher_type").notNull().default("none"),
|
||||
matcherValue: text("matcher_value"),
|
||||
tlsTermination: integer("tls_termination", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocolVersion: text("proxy_protocol_version"),
|
||||
proxyProtocolReceive: integer("proxy_protocol_receive", { mode: "boolean" }).notNull().default(false),
|
||||
ownerUserId: integer("owner_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
matcherType: text("matcherType").notNull().default("none"),
|
||||
matcherValue: text("matcherValue"),
|
||||
tlsTermination: integer("tlsTermination", { mode: "boolean" }).notNull().default(false),
|
||||
proxyProtocolVersion: text("proxyProtocolVersion"),
|
||||
proxyProtocolReceive: integer("proxyProtocolReceive", { mode: "boolean" }).notNull().default(false),
|
||||
ownerUserId: integer("ownerUserId").references(() => users.id, { onDelete: "set null" }),
|
||||
meta: text("meta"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
updatedAt: text("updatedAt").notNull(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import db, { nowIso } from "./db";
|
||||
import { config } from "./config";
|
||||
import { users } from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users, accounts } from "./db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Ensures the admin user from environment variables exists in the database.
|
||||
@@ -37,9 +38,13 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
subject,
|
||||
passwordHash,
|
||||
role: "admin",
|
||||
username: config.adminUsername.toLowerCase(),
|
||||
displayUsername: config.adminUsername,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(users.id, adminId));
|
||||
// Ensure credential account row exists for Better Auth
|
||||
await ensureCredentialAccount(adminId, passwordHash);
|
||||
console.log(`Updated admin user: ${config.adminUsername}`);
|
||||
return;
|
||||
}
|
||||
@@ -54,6 +59,8 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
role: "admin",
|
||||
provider,
|
||||
subject,
|
||||
username: config.adminUsername.toLowerCase(),
|
||||
displayUsername: config.adminUsername,
|
||||
avatarUrl: null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
@@ -61,4 +68,35 @@ export async function ensureAdminUser(): Promise<void> {
|
||||
});
|
||||
|
||||
console.log(`Created admin user: ${config.adminUsername}`);
|
||||
|
||||
// Ensure credential account row exists for Better Auth
|
||||
await ensureCredentialAccount(adminId, passwordHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a credential account row exists in the accounts table for Better Auth.
|
||||
* Better Auth requires an accounts row with providerId="credential" and the password hash.
|
||||
*/
|
||||
async function ensureCredentialAccount(userId: number, passwordHash: string): Promise<void> {
|
||||
const now = nowIso();
|
||||
const existing = await db.select().from(accounts).where(
|
||||
and(eq(accounts.userId, userId), eq(accounts.providerId, "credential"))
|
||||
).get();
|
||||
|
||||
if (existing) {
|
||||
// Update password hash if changed
|
||||
await db.update(accounts).set({
|
||||
password: passwordHash,
|
||||
updatedAt: now,
|
||||
}).where(eq(accounts.id, existing.id));
|
||||
} else {
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: userId.toString(),
|
||||
providerId: "credential",
|
||||
password: passwordHash,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import { asc, eq, inArray, count } from "drizzle-orm";
|
||||
export type AccessListEntry = {
|
||||
id: number;
|
||||
username: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AccessList = {
|
||||
@@ -17,8 +17,8 @@ export type AccessList = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
entries: AccessListEntry[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type AccessListInput = {
|
||||
@@ -34,8 +34,8 @@ function buildEntry(row: AccessListEntryRow): AccessListEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ function toAccessList(row: AccessListRow, entries: AccessListEntryRow[]): Access
|
||||
.slice()
|
||||
.sort((a, b) => a.username.localeCompare(b.username))
|
||||
.map(buildEntry),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { NotFoundError } from "../api-auth";
|
||||
export type ApiToken = {
|
||||
id: number;
|
||||
name: string;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
last_used_at: string | null;
|
||||
expires_at: string | null;
|
||||
createdBy: number;
|
||||
createdAt: string;
|
||||
lastUsedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
type ApiTokenRow = typeof apiTokens.$inferSelect;
|
||||
@@ -19,10 +19,10 @@ function toApiToken(row: ApiTokenRow): ApiToken {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
created_by: row.createdBy,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
last_used_at: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
|
||||
expires_at: row.expiresAt ? toIso(row.expiresAt) : null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
|
||||
expiresAt: row.expiresAt ? toIso(row.expiresAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@ import { desc, like, or, count } from "drizzle-orm";
|
||||
|
||||
export type AuditEvent = {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
userId: number | null;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
entityType: string;
|
||||
entityId: number | null;
|
||||
summary: string | null;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// Escape LIKE metacharacters so user input is treated as literal text
|
||||
@@ -57,12 +57,12 @@ export async function listAuditEvents(
|
||||
|
||||
return events.map((event) => ({
|
||||
id: event.id,
|
||||
user_id: event.userId,
|
||||
userId: event.userId,
|
||||
action: event.action,
|
||||
entity_type: event.entityType,
|
||||
entity_id: event.entityId,
|
||||
entityType: event.entityType,
|
||||
entityId: event.entityId,
|
||||
summary: event.summary,
|
||||
created_at: toIso(event.createdAt)!,
|
||||
createdAt: toIso(event.createdAt)!,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -16,16 +16,16 @@ function tryParseJson<T>(value: string | null | undefined, fallback: T): T {
|
||||
export type CaCertificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
has_private_key: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
certificatePem: string;
|
||||
hasPrivateKey: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CaCertificateInput = {
|
||||
name: string;
|
||||
certificate_pem: string;
|
||||
private_key_pem?: string;
|
||||
certificatePem: string;
|
||||
privateKeyPem?: string;
|
||||
};
|
||||
|
||||
type CaCertificateRow = typeof caCertificates.$inferSelect;
|
||||
@@ -34,10 +34,10 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
certificate_pem: row.certificatePem,
|
||||
has_private_key: !!row.privateKeyPem,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
certificatePem: row.certificatePem,
|
||||
hasPrivateKey: !!row.privateKeyPem,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -66,8 +66,8 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
|
||||
.insert(caCertificates)
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
privateKeyPem: input.private_key_pem?.trim() ?? null,
|
||||
certificatePem: input.certificatePem.trim(),
|
||||
privateKeyPem: input.privateKeyPem?.trim() ?? null,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -100,8 +100,8 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
|
||||
.update(caCertificates)
|
||||
.set({
|
||||
name: input.name?.trim() ?? existing.name,
|
||||
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem,
|
||||
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}),
|
||||
certificatePem: input.certificatePem?.trim() ?? existing.certificatePem,
|
||||
...(input.privateKeyPem !== undefined ? { privateKeyPem: input.privateKeyPem?.trim() ?? null } : {}),
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(caCertificates.id, id));
|
||||
|
||||
@@ -10,23 +10,23 @@ export type Certificate = {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew: boolean;
|
||||
provider_options: Record<string, unknown> | null;
|
||||
certificate_pem: string | null;
|
||||
private_key_pem: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
domainNames: string[];
|
||||
autoRenew: boolean;
|
||||
providerOptions: Record<string, unknown> | null;
|
||||
certificatePem: string | null;
|
||||
privateKeyPem: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type CertificateInput = {
|
||||
name: string;
|
||||
type: CertificateType;
|
||||
domain_names: string[];
|
||||
auto_renew?: boolean;
|
||||
provider_options?: Record<string, unknown> | null;
|
||||
certificate_pem?: string | null;
|
||||
private_key_pem?: string | null;
|
||||
domainNames: string[];
|
||||
autoRenew?: boolean;
|
||||
providerOptions?: Record<string, unknown> | null;
|
||||
certificatePem?: string | null;
|
||||
privateKeyPem?: string | null;
|
||||
};
|
||||
|
||||
type CertificateRow = typeof certificates.$inferSelect;
|
||||
@@ -36,13 +36,13 @@ function parseCertificate(row: CertificateRow): Certificate {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type as CertificateType,
|
||||
domain_names: JSON.parse(row.domainNames),
|
||||
auto_renew: row.autoRenew,
|
||||
provider_options: row.providerOptions ? JSON.parse(row.providerOptions) : null,
|
||||
certificate_pem: row.certificatePem,
|
||||
private_key_pem: row.privateKeyPem,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
domainNames: JSON.parse(row.domainNames),
|
||||
autoRenew: row.autoRenew,
|
||||
providerOptions: row.providerOptions ? JSON.parse(row.providerOptions) : null,
|
||||
certificatePem: row.certificatePem,
|
||||
privateKeyPem: row.privateKeyPem,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ export async function getCertificate(id: number): Promise<Certificate | null> {
|
||||
}
|
||||
|
||||
function validateCertificateInput(input: CertificateInput) {
|
||||
if (!input.domain_names || input.domain_names.length === 0) {
|
||||
if (!input.domainNames || input.domainNames.length === 0) {
|
||||
throw new Error("At least one domain is required for a certificate");
|
||||
}
|
||||
if (input.type === "imported") {
|
||||
if (!input.certificate_pem || !input.private_key_pem) {
|
||||
if (!input.certificatePem || !input.privateKeyPem) {
|
||||
throw new Error("Imported certificates require certificate and key PEM data");
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,12 @@ export async function createCertificate(input: CertificateInput, actorUserId: nu
|
||||
name: input.name.trim(),
|
||||
type: input.type,
|
||||
domainNames: JSON.stringify(
|
||||
Array.from(new Set(input.domain_names.map((domain) => domain.trim().toLowerCase())))
|
||||
Array.from(new Set(input.domainNames.map((domain) => domain.trim().toLowerCase())))
|
||||
),
|
||||
autoRenew: input.auto_renew ?? true,
|
||||
providerOptions: input.provider_options ? JSON.stringify(input.provider_options) : null,
|
||||
certificatePem: input.certificate_pem ?? null,
|
||||
privateKeyPem: input.private_key_pem ?? null,
|
||||
autoRenew: input.autoRenew ?? true,
|
||||
providerOptions: input.providerOptions ? JSON.stringify(input.providerOptions) : null,
|
||||
certificatePem: input.certificatePem ?? null,
|
||||
privateKeyPem: input.privateKeyPem ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: actorUserId
|
||||
@@ -114,11 +114,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
const merged: CertificateInput = {
|
||||
name: input.name ?? existing.name,
|
||||
type: input.type ?? existing.type,
|
||||
domain_names: input.domain_names ?? existing.domain_names,
|
||||
auto_renew: input.auto_renew ?? existing.auto_renew,
|
||||
provider_options: input.provider_options ?? existing.provider_options,
|
||||
certificate_pem: input.certificate_pem ?? existing.certificate_pem,
|
||||
private_key_pem: input.private_key_pem ?? existing.private_key_pem
|
||||
domainNames: input.domainNames ?? existing.domainNames,
|
||||
autoRenew: input.autoRenew ?? existing.autoRenew,
|
||||
providerOptions: input.providerOptions ?? existing.providerOptions,
|
||||
certificatePem: input.certificatePem ?? existing.certificatePem,
|
||||
privateKeyPem: input.privateKeyPem ?? existing.privateKeyPem
|
||||
};
|
||||
|
||||
validateCertificateInput(merged);
|
||||
@@ -129,11 +129,11 @@ export async function updateCertificate(id: number, input: Partial<CertificateIn
|
||||
.set({
|
||||
name: merged.name.trim(),
|
||||
type: merged.type,
|
||||
domainNames: JSON.stringify(Array.from(new Set(merged.domain_names))),
|
||||
autoRenew: merged.auto_renew,
|
||||
providerOptions: merged.provider_options ? JSON.stringify(merged.provider_options) : null,
|
||||
certificatePem: merged.certificate_pem ?? null,
|
||||
privateKeyPem: merged.private_key_pem ?? null,
|
||||
domainNames: JSON.stringify(Array.from(new Set(merged.domainNames))),
|
||||
autoRenew: merged.autoRenew,
|
||||
providerOptions: merged.providerOptions ? JSON.stringify(merged.providerOptions) : null,
|
||||
certificatePem: merged.certificatePem ?? null,
|
||||
privateKeyPem: merged.privateKeyPem ?? null,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(certificates.id, id));
|
||||
|
||||
@@ -92,9 +92,9 @@ export async function consumeRedirectIntent(
|
||||
|
||||
export type ForwardAuthSession = {
|
||||
id: number;
|
||||
user_id: number;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
userId: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function createForwardAuthSession(
|
||||
@@ -118,9 +118,9 @@ export async function createForwardAuthSession(
|
||||
rawToken,
|
||||
session: {
|
||||
id: row.id,
|
||||
user_id: row.userId,
|
||||
expires_at: toIso(row.expiresAt)!,
|
||||
created_at: toIso(row.createdAt)!
|
||||
userId: row.userId,
|
||||
expiresAt: toIso(row.expiresAt)!,
|
||||
createdAt: toIso(row.createdAt)!
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -145,9 +145,9 @@ export async function listForwardAuthSessions(): Promise<ForwardAuthSession[]> {
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
user_id: r.userId,
|
||||
expires_at: toIso(r.expiresAt)!,
|
||||
created_at: toIso(r.createdAt)!
|
||||
userId: r.userId,
|
||||
expiresAt: toIso(r.expiresAt)!,
|
||||
createdAt: toIso(r.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -232,10 +232,10 @@ export async function redeemExchangeCode(
|
||||
|
||||
export type ForwardAuthAccessEntry = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
user_id: number | null;
|
||||
group_id: number | null;
|
||||
created_at: string;
|
||||
proxyHostId: number;
|
||||
userId: number | null;
|
||||
groupId: number | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function checkHostAccess(
|
||||
@@ -313,10 +313,10 @@ export async function getForwardAuthAccessForHost(
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
proxy_host_id: r.proxyHostId,
|
||||
user_id: r.userId,
|
||||
group_id: r.groupId,
|
||||
created_at: toIso(r.createdAt)!
|
||||
proxyHostId: r.proxyHostId,
|
||||
userId: r.userId,
|
||||
groupId: r.groupId,
|
||||
createdAt: toIso(r.createdAt)!
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,15 +8,15 @@ export type Group = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
members: GroupMember[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type GroupMember = {
|
||||
user_id: number;
|
||||
userId: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
created_at: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GroupInput = {
|
||||
@@ -32,8 +32,8 @@ function toGroup(row: GroupRow, members: GroupMember[]): Group {
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
members,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -61,10 +61,10 @@ export async function listGroups(): Promise<Group[]> {
|
||||
for (const m of allMembers) {
|
||||
const bucket = membersByGroup.get(m.groupId) ?? [];
|
||||
bucket.push({
|
||||
user_id: m.userId,
|
||||
userId: m.userId,
|
||||
email: m.email,
|
||||
name: m.name,
|
||||
created_at: toIso(m.createdAt)!
|
||||
createdAt: toIso(m.createdAt)!
|
||||
});
|
||||
membersByGroup.set(m.groupId, bucket);
|
||||
}
|
||||
@@ -97,10 +97,10 @@ export async function getGroup(id: number): Promise<Group | null> {
|
||||
return toGroup(
|
||||
group,
|
||||
members.map((m) => ({
|
||||
user_id: m.userId,
|
||||
userId: m.userId,
|
||||
email: m.email,
|
||||
name: m.name,
|
||||
created_at: toIso(m.createdAt)!
|
||||
createdAt: toIso(m.createdAt)!
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import { encryptSecret } from "../secret";
|
||||
export type Instance = {
|
||||
id: number;
|
||||
name: string;
|
||||
base_url: string;
|
||||
baseUrl: string;
|
||||
enabled: boolean;
|
||||
has_token: boolean;
|
||||
last_sync_at: string | null;
|
||||
last_sync_error: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
hasToken: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastSyncError: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type InstanceInput = {
|
||||
@@ -28,13 +28,13 @@ function toInstance(row: InstanceRow): Instance {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
base_url: row.baseUrl,
|
||||
baseUrl: row.baseUrl,
|
||||
enabled: Boolean(row.enabled),
|
||||
has_token: row.apiToken.length > 0,
|
||||
last_sync_at: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
|
||||
last_sync_error: row.lastSyncError ?? null,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
hasToken: row.apiToken.length > 0,
|
||||
lastSyncAt: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
|
||||
lastSyncError: row.lastSyncError ?? null,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,26 +6,26 @@ import { desc, eq } from "drizzle-orm";
|
||||
|
||||
export type IssuedClientCertificate = {
|
||||
id: number;
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
revoked_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
caCertificateId: number;
|
||||
commonName: string;
|
||||
serialNumber: string;
|
||||
fingerprintSha256: string;
|
||||
certificatePem: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
revokedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type IssuedClientCertificateInput = {
|
||||
ca_certificate_id: number;
|
||||
common_name: string;
|
||||
serial_number: string;
|
||||
fingerprint_sha256: string;
|
||||
certificate_pem: string;
|
||||
valid_from: string;
|
||||
valid_to: string;
|
||||
caCertificateId: number;
|
||||
commonName: string;
|
||||
serialNumber: string;
|
||||
fingerprintSha256: string;
|
||||
certificatePem: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
};
|
||||
|
||||
type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
|
||||
@@ -33,16 +33,16 @@ type IssuedClientCertificateRow = typeof issuedClientCertificates.$inferSelect;
|
||||
function parseIssuedClientCertificate(row: IssuedClientCertificateRow): IssuedClientCertificate {
|
||||
return {
|
||||
id: row.id,
|
||||
ca_certificate_id: row.caCertificateId,
|
||||
common_name: row.commonName,
|
||||
serial_number: row.serialNumber,
|
||||
fingerprint_sha256: row.fingerprintSha256,
|
||||
certificate_pem: row.certificatePem,
|
||||
valid_from: toIso(row.validFrom)!,
|
||||
valid_to: toIso(row.validTo)!,
|
||||
revoked_at: toIso(row.revokedAt),
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!
|
||||
caCertificateId: row.caCertificateId,
|
||||
commonName: row.commonName,
|
||||
serialNumber: row.serialNumber,
|
||||
fingerprintSha256: row.fingerprintSha256,
|
||||
certificatePem: row.certificatePem,
|
||||
validFrom: toIso(row.validFrom)!,
|
||||
validTo: toIso(row.validTo)!,
|
||||
revokedAt: toIso(row.revokedAt),
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,13 +69,13 @@ export async function createIssuedClientCertificate(
|
||||
const [record] = await db
|
||||
.insert(issuedClientCertificates)
|
||||
.values({
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
commonName: input.common_name.trim(),
|
||||
serialNumber: input.serial_number.trim(),
|
||||
fingerprintSha256: input.fingerprint_sha256.trim(),
|
||||
certificatePem: input.certificate_pem.trim(),
|
||||
validFrom: input.valid_from,
|
||||
validTo: input.valid_to,
|
||||
caCertificateId: input.caCertificateId,
|
||||
commonName: input.commonName.trim(),
|
||||
serialNumber: input.serialNumber.trim(),
|
||||
fingerprintSha256: input.fingerprintSha256.trim(),
|
||||
certificatePem: input.certificatePem.trim(),
|
||||
validFrom: input.validFrom,
|
||||
validTo: input.validTo,
|
||||
createdBy: actorUserId,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -91,10 +91,10 @@ export async function createIssuedClientCertificate(
|
||||
action: "create",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: record.id,
|
||||
summary: `Issued client certificate ${input.common_name}`,
|
||||
summary: `Issued client certificate ${input.commonName}`,
|
||||
data: {
|
||||
caCertificateId: input.ca_certificate_id,
|
||||
serialNumber: input.serial_number
|
||||
caCertificateId: input.caCertificateId,
|
||||
serialNumber: input.serialNumber
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
@@ -109,7 +109,7 @@ export async function revokeIssuedClientCertificate(
|
||||
if (!existing) {
|
||||
throw new Error("Issued client certificate not found");
|
||||
}
|
||||
if (existing.revoked_at) {
|
||||
if (existing.revokedAt) {
|
||||
throw new Error("Issued client certificate is already revoked");
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ export async function revokeIssuedClientCertificate(
|
||||
action: "revoke",
|
||||
entityType: "issued_client_certificate",
|
||||
entityId: id,
|
||||
summary: `Revoked client certificate ${existing.common_name}`,
|
||||
summary: `Revoked client certificate ${existing.commonName}`,
|
||||
data: {
|
||||
caCertificateId: existing.ca_certificate_id,
|
||||
serialNumber: existing.serial_number
|
||||
caCertificateId: existing.caCertificateId,
|
||||
serialNumber: existing.serialNumber
|
||||
}
|
||||
});
|
||||
await applyCaddyConfig();
|
||||
|
||||
@@ -113,41 +113,41 @@ export type L4ProxyHost = {
|
||||
id: number;
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
listenAddress: string;
|
||||
upstreams: string[];
|
||||
matcher_type: L4MatcherType;
|
||||
matcher_value: string[];
|
||||
tls_termination: boolean;
|
||||
proxy_protocol_version: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive: boolean;
|
||||
matcherType: L4MatcherType;
|
||||
matcherValue: string[];
|
||||
tlsTermination: boolean;
|
||||
proxyProtocolVersion: L4ProxyProtocolVersion | null;
|
||||
proxyProtocolReceive: boolean;
|
||||
enabled: boolean;
|
||||
meta: L4ProxyHostMeta | null;
|
||||
load_balancer: L4LoadBalancerConfig | null;
|
||||
dns_resolver: L4DnsResolverConfig | null;
|
||||
upstream_dns_resolution: L4UpstreamDnsResolutionConfig | null;
|
||||
loadBalancer: L4LoadBalancerConfig | null;
|
||||
dnsResolver: L4DnsResolverConfig | null;
|
||||
upstreamDnsResolution: L4UpstreamDnsResolutionConfig | null;
|
||||
geoblock: L4GeoBlockConfig | null;
|
||||
geoblock_mode: L4GeoBlockMode;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
geoblockMode: L4GeoBlockMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type L4ProxyHostInput = {
|
||||
name: string;
|
||||
protocol: L4Protocol;
|
||||
listen_address: string;
|
||||
listenAddress: string;
|
||||
upstreams: string[];
|
||||
matcher_type?: L4MatcherType;
|
||||
matcher_value?: string[];
|
||||
tls_termination?: boolean;
|
||||
proxy_protocol_version?: L4ProxyProtocolVersion | null;
|
||||
proxy_protocol_receive?: boolean;
|
||||
matcherType?: L4MatcherType;
|
||||
matcherValue?: string[];
|
||||
tlsTermination?: boolean;
|
||||
proxyProtocolVersion?: L4ProxyProtocolVersion | null;
|
||||
proxyProtocolReceive?: boolean;
|
||||
enabled?: boolean;
|
||||
meta?: L4ProxyHostMeta | null;
|
||||
load_balancer?: Partial<L4LoadBalancerConfig> | null;
|
||||
dns_resolver?: Partial<L4DnsResolverConfig> | null;
|
||||
upstream_dns_resolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
|
||||
loadBalancer?: Partial<L4LoadBalancerConfig> | null;
|
||||
dnsResolver?: Partial<L4DnsResolverConfig> | null;
|
||||
upstreamDnsResolution?: Partial<L4UpstreamDnsResolutionConfig> | null;
|
||||
geoblock?: L4GeoBlockConfig | null;
|
||||
geoblock_mode?: L4GeoBlockMode;
|
||||
geoblockMode?: L4GeoBlockMode;
|
||||
};
|
||||
|
||||
const VALID_PROTOCOLS: L4Protocol[] = ["tcp", "udp"];
|
||||
@@ -363,22 +363,22 @@ function parseL4ProxyHost(row: L4ProxyHostRow): L4ProxyHost {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
protocol: row.protocol as L4Protocol,
|
||||
listen_address: row.listenAddress,
|
||||
listenAddress: row.listenAddress,
|
||||
upstreams: safeJsonParse<string[]>(row.upstreams, []),
|
||||
matcher_type: (row.matcherType as L4MatcherType) || "none",
|
||||
matcher_value: safeJsonParse<string[]>(row.matcherValue, []),
|
||||
tls_termination: row.tlsTermination,
|
||||
proxy_protocol_version: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
|
||||
proxy_protocol_receive: row.proxyProtocolReceive,
|
||||
matcherType: (row.matcherType as L4MatcherType) || "none",
|
||||
matcherValue: safeJsonParse<string[]>(row.matcherValue, []),
|
||||
tlsTermination: row.tlsTermination,
|
||||
proxyProtocolVersion: row.proxyProtocolVersion as L4ProxyProtocolVersion | null,
|
||||
proxyProtocolReceive: row.proxyProtocolReceive,
|
||||
enabled: row.enabled,
|
||||
meta: Object.keys(meta).length > 0 ? meta : null,
|
||||
load_balancer: hydrateL4LoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateL4DnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
loadBalancer: hydrateL4LoadBalancer(meta.load_balancer),
|
||||
dnsResolver: hydrateL4DnsResolver(meta.dns_resolver),
|
||||
upstreamDnsResolution: hydrateL4UpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: meta.geoblock?.enabled ? meta.geoblock : null,
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
geoblockMode: meta.geoblock_mode ?? "merge",
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
if (!input.protocol || !VALID_PROTOCOLS.includes(input.protocol)) {
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
if (!input.listen_address?.trim()) {
|
||||
if (!input.listenAddress?.trim()) {
|
||||
throw new Error("Listen address is required");
|
||||
}
|
||||
if (!input.upstreams || input.upstreams.length === 0) {
|
||||
@@ -398,8 +398,8 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
}
|
||||
}
|
||||
|
||||
if (input.listen_address !== undefined) {
|
||||
const addr = input.listen_address.trim();
|
||||
if (input.listenAddress !== undefined) {
|
||||
const addr = input.listenAddress.trim();
|
||||
// Must be :PORT or HOST:PORT
|
||||
const portMatch = addr.match(/:(\d+)$/);
|
||||
if (!portMatch) {
|
||||
@@ -415,22 +415,22 @@ function validateL4Input(input: L4ProxyHostInput | Partial<L4ProxyHostInput>, is
|
||||
throw new Error("Protocol must be 'tcp' or 'udp'");
|
||||
}
|
||||
|
||||
if (input.matcher_type !== undefined && !VALID_MATCHER_TYPES.includes(input.matcher_type)) {
|
||||
if (input.matcherType !== undefined && !VALID_MATCHER_TYPES.includes(input.matcherType)) {
|
||||
throw new Error(`Matcher type must be one of: ${VALID_MATCHER_TYPES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (input.matcher_type === "tls_sni" || input.matcher_type === "http_host") {
|
||||
if (!input.matcher_value || input.matcher_value.length === 0) {
|
||||
if (input.matcherType === "tls_sni" || input.matcherType === "http_host") {
|
||||
if (!input.matcherValue || input.matcherValue.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
}
|
||||
|
||||
if (input.tls_termination && input.protocol === "udp") {
|
||||
if (input.tlsTermination && input.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
|
||||
if (input.proxy_protocol_version !== undefined && input.proxy_protocol_version !== null) {
|
||||
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxy_protocol_version)) {
|
||||
if (input.proxyProtocolVersion !== undefined && input.proxyProtocolVersion !== null) {
|
||||
if (!VALID_PROXY_PROTOCOL_VERSIONS.includes(input.proxyProtocolVersion)) {
|
||||
throw new Error("Proxy protocol version must be 'v1' or 'v2'");
|
||||
}
|
||||
}
|
||||
@@ -465,10 +465,10 @@ export async function countL4ProxyHosts(search?: string): Promise<number> {
|
||||
const L4_SORT_COLUMNS: Record<string, any> = {
|
||||
name: l4ProxyHosts.name,
|
||||
protocol: l4ProxyHosts.protocol,
|
||||
listen_address: l4ProxyHosts.listenAddress,
|
||||
listenAddress: l4ProxyHosts.listenAddress,
|
||||
upstreams: l4ProxyHosts.upstreams,
|
||||
enabled: l4ProxyHosts.enabled,
|
||||
created_at: l4ProxyHosts.createdAt,
|
||||
createdAt: l4ProxyHosts.createdAt,
|
||||
};
|
||||
|
||||
export async function listL4ProxyHostsPaginated(
|
||||
@@ -506,21 +506,21 @@ export async function createL4ProxyHost(input: L4ProxyHostInput, actorUserId: nu
|
||||
.values({
|
||||
name: input.name.trim(),
|
||||
protocol: input.protocol,
|
||||
listenAddress: input.listen_address.trim(),
|
||||
listenAddress: input.listenAddress.trim(),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
matcherType: input.matcher_type ?? "none",
|
||||
matcherValue: input.matcher_value ? JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) : null,
|
||||
tlsTermination: input.tls_termination ?? false,
|
||||
proxyProtocolVersion: input.proxy_protocol_version ?? null,
|
||||
proxyProtocolReceive: input.proxy_protocol_receive ?? false,
|
||||
matcherType: input.matcherType ?? "none",
|
||||
matcherValue: input.matcherValue ? JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) : null,
|
||||
tlsTermination: input.tlsTermination ?? false,
|
||||
proxyProtocolVersion: input.proxyProtocolVersion ?? null,
|
||||
proxyProtocolReceive: input.proxyProtocolReceive ?? false,
|
||||
ownerUserId: actorUserId,
|
||||
meta: (() => {
|
||||
const meta: L4ProxyHostMeta = { ...(input.meta ?? {}) };
|
||||
if (input.load_balancer) meta.load_balancer = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (input.dns_resolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (input.upstream_dns_resolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (input.loadBalancer) meta.load_balancer = dehydrateL4LoadBalancer(input.loadBalancer);
|
||||
if (input.dnsResolver) meta.dns_resolver = dehydrateL4DnsResolver(input.dnsResolver);
|
||||
if (input.upstreamDnsResolution) meta.upstream_dns_resolution = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
|
||||
if (input.geoblock) meta.geoblock = input.geoblock;
|
||||
if (input.geoblock_mode && input.geoblock_mode !== "merge") meta.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode && input.geoblockMode !== "merge") meta.geoblock_mode = input.geoblockMode;
|
||||
return Object.keys(meta).length > 0 ? JSON.stringify(meta) : null;
|
||||
})(),
|
||||
enabled: input.enabled ?? true,
|
||||
@@ -562,14 +562,14 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
// For validation, merge with existing to check cross-field constraints
|
||||
const merged = {
|
||||
protocol: input.protocol ?? existing.protocol,
|
||||
tls_termination: input.tls_termination ?? existing.tls_termination,
|
||||
matcher_type: input.matcher_type ?? existing.matcher_type,
|
||||
matcher_value: input.matcher_value ?? existing.matcher_value,
|
||||
tlsTermination: input.tlsTermination ?? existing.tlsTermination,
|
||||
matcherType: input.matcherType ?? existing.matcherType,
|
||||
matcherValue: input.matcherValue ?? existing.matcherValue,
|
||||
};
|
||||
if (merged.tls_termination && merged.protocol === "udp") {
|
||||
if (merged.tlsTermination && merged.protocol === "udp") {
|
||||
throw new Error("TLS termination is only supported with TCP protocol");
|
||||
}
|
||||
if ((merged.matcher_type === "tls_sni" || merged.matcher_type === "http_host") && merged.matcher_value.length === 0) {
|
||||
if ((merged.matcherType === "tls_sni" || merged.matcherType === "http_host") && merged.matcherValue.length === 0) {
|
||||
throw new Error("Matcher value is required for TLS SNI and HTTP Host matchers");
|
||||
}
|
||||
|
||||
@@ -581,57 +581,57 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
.set({
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.protocol !== undefined ? { protocol: input.protocol } : {}),
|
||||
...(input.listen_address !== undefined ? { listenAddress: input.listen_address.trim() } : {}),
|
||||
...(input.listenAddress !== undefined ? { listenAddress: input.listenAddress.trim() } : {}),
|
||||
...(input.upstreams !== undefined
|
||||
? { upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))) }
|
||||
: {}),
|
||||
...(input.matcher_type !== undefined ? { matcherType: input.matcher_type } : {}),
|
||||
...(input.matcher_value !== undefined
|
||||
? { matcherValue: JSON.stringify(input.matcher_value.map((v) => v.trim()).filter(Boolean)) }
|
||||
...(input.matcherType !== undefined ? { matcherType: input.matcherType } : {}),
|
||||
...(input.matcherValue !== undefined
|
||||
? { matcherValue: JSON.stringify(input.matcherValue.map((v) => v.trim()).filter(Boolean)) }
|
||||
: {}),
|
||||
...(input.tls_termination !== undefined ? { tlsTermination: input.tls_termination } : {}),
|
||||
...(input.proxy_protocol_version !== undefined ? { proxyProtocolVersion: input.proxy_protocol_version } : {}),
|
||||
...(input.proxy_protocol_receive !== undefined ? { proxyProtocolReceive: input.proxy_protocol_receive } : {}),
|
||||
...(input.tlsTermination !== undefined ? { tlsTermination: input.tlsTermination } : {}),
|
||||
...(input.proxyProtocolVersion !== undefined ? { proxyProtocolVersion: input.proxyProtocolVersion } : {}),
|
||||
...(input.proxyProtocolReceive !== undefined ? { proxyProtocolReceive: input.proxyProtocolReceive } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(() => {
|
||||
const hasMetaChanges =
|
||||
input.meta !== undefined ||
|
||||
input.load_balancer !== undefined ||
|
||||
input.dns_resolver !== undefined ||
|
||||
input.upstream_dns_resolution !== undefined;
|
||||
input.loadBalancer !== undefined ||
|
||||
input.dnsResolver !== undefined ||
|
||||
input.upstreamDnsResolution !== undefined;
|
||||
if (!hasMetaChanges) return {};
|
||||
|
||||
// Start from existing meta
|
||||
const existingMeta: L4ProxyHostMeta = {
|
||||
...(existing.load_balancer ? { load_balancer: dehydrateL4LoadBalancer(existing.load_balancer) } : {}),
|
||||
...(existing.dns_resolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dns_resolver) } : {}),
|
||||
...(existing.upstream_dns_resolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstream_dns_resolution) } : {}),
|
||||
...(existing.loadBalancer ? { load_balancer: dehydrateL4LoadBalancer(existing.loadBalancer) } : {}),
|
||||
...(existing.dnsResolver ? { dns_resolver: dehydrateL4DnsResolver(existing.dnsResolver) } : {}),
|
||||
...(existing.upstreamDnsResolution ? { upstream_dns_resolution: dehydrateL4UpstreamDnsResolution(existing.upstreamDnsResolution) } : {}),
|
||||
...(existing.geoblock ? { geoblock: existing.geoblock } : {}),
|
||||
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
|
||||
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
|
||||
};
|
||||
|
||||
// Apply direct meta override if provided
|
||||
const meta: L4ProxyHostMeta = input.meta !== undefined ? { ...(input.meta ?? {}) } : { ...existingMeta };
|
||||
|
||||
// Apply structured field overrides
|
||||
if (input.load_balancer !== undefined) {
|
||||
const lb = dehydrateL4LoadBalancer(input.load_balancer);
|
||||
if (input.loadBalancer !== undefined) {
|
||||
const lb = dehydrateL4LoadBalancer(input.loadBalancer);
|
||||
if (lb) {
|
||||
meta.load_balancer = lb;
|
||||
} else {
|
||||
delete meta.load_balancer;
|
||||
}
|
||||
}
|
||||
if (input.dns_resolver !== undefined) {
|
||||
const dr = dehydrateL4DnsResolver(input.dns_resolver);
|
||||
if (input.dnsResolver !== undefined) {
|
||||
const dr = dehydrateL4DnsResolver(input.dnsResolver);
|
||||
if (dr) {
|
||||
meta.dns_resolver = dr;
|
||||
} else {
|
||||
delete meta.dns_resolver;
|
||||
}
|
||||
}
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
const udr = dehydrateL4UpstreamDnsResolution(input.upstream_dns_resolution);
|
||||
if (input.upstreamDnsResolution !== undefined) {
|
||||
const udr = dehydrateL4UpstreamDnsResolution(input.upstreamDnsResolution);
|
||||
if (udr) {
|
||||
meta.upstream_dns_resolution = udr;
|
||||
} else {
|
||||
@@ -645,9 +645,9 @@ export async function updateL4ProxyHost(id: number, input: Partial<L4ProxyHostIn
|
||||
delete meta.geoblock;
|
||||
}
|
||||
}
|
||||
if (input.geoblock_mode !== undefined) {
|
||||
if (input.geoblock_mode !== "merge") {
|
||||
meta.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode !== undefined) {
|
||||
if (input.geoblockMode !== "merge") {
|
||||
meta.geoblock_mode = input.geoblockMode;
|
||||
} else {
|
||||
delete meta.geoblock_mode;
|
||||
}
|
||||
|
||||
@@ -8,23 +8,23 @@ import { asc, desc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
export type MtlsAccessRule = {
|
||||
id: number;
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids: number[];
|
||||
allowed_cert_ids: number[];
|
||||
deny_all: boolean;
|
||||
proxyHostId: number;
|
||||
pathPattern: string;
|
||||
allowedRoleIds: number[];
|
||||
allowedCertIds: number[];
|
||||
denyAll: boolean;
|
||||
priority: number;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MtlsAccessRuleInput = {
|
||||
proxy_host_id: number;
|
||||
path_pattern: string;
|
||||
allowed_role_ids?: number[];
|
||||
allowed_cert_ids?: number[];
|
||||
deny_all?: boolean;
|
||||
proxyHostId: number;
|
||||
pathPattern: string;
|
||||
allowedRoleIds?: number[];
|
||||
allowedCertIds?: number[];
|
||||
denyAll?: boolean;
|
||||
priority?: number;
|
||||
description?: string | null;
|
||||
};
|
||||
@@ -44,15 +44,15 @@ function parseJsonIds(raw: string): number[] {
|
||||
function toMtlsAccessRule(row: RuleRow): MtlsAccessRule {
|
||||
return {
|
||||
id: row.id,
|
||||
proxy_host_id: row.proxyHostId,
|
||||
path_pattern: row.pathPattern,
|
||||
allowed_role_ids: parseJsonIds(row.allowedRoleIds),
|
||||
allowed_cert_ids: parseJsonIds(row.allowedCertIds),
|
||||
deny_all: row.denyAll,
|
||||
proxyHostId: row.proxyHostId,
|
||||
pathPattern: row.pathPattern,
|
||||
allowedRoleIds: parseJsonIds(row.allowedRoleIds),
|
||||
allowedCertIds: parseJsonIds(row.allowedCertIds),
|
||||
denyAll: row.denyAll,
|
||||
priority: row.priority,
|
||||
description: row.description,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,11 +82,11 @@ export async function createMtlsAccessRule(
|
||||
const [record] = await db
|
||||
.insert(mtlsAccessRules)
|
||||
.values({
|
||||
proxyHostId: input.proxy_host_id,
|
||||
pathPattern: input.path_pattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowed_role_ids ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowed_cert_ids ?? []),
|
||||
denyAll: input.deny_all ?? false,
|
||||
proxyHostId: input.proxyHostId,
|
||||
pathPattern: input.pathPattern.trim(),
|
||||
allowedRoleIds: JSON.stringify(input.allowedRoleIds ?? []),
|
||||
allowedCertIds: JSON.stringify(input.allowedCertIds ?? []),
|
||||
denyAll: input.denyAll ?? false,
|
||||
priority: input.priority ?? 0,
|
||||
description: input.description ?? null,
|
||||
createdBy: actorUserId,
|
||||
@@ -102,7 +102,7 @@ export async function createMtlsAccessRule(
|
||||
action: "create",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: record.id,
|
||||
summary: `Created mTLS access rule for path ${input.path_pattern} on proxy host ${input.proxy_host_id}`,
|
||||
summary: `Created mTLS access rule for path ${input.pathPattern} on proxy host ${input.proxyHostId}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
@@ -111,7 +111,7 @@ export async function createMtlsAccessRule(
|
||||
|
||||
export async function updateMtlsAccessRule(
|
||||
id: number,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxy_host_id">>,
|
||||
input: Partial<Omit<MtlsAccessRuleInput, "proxyHostId">>,
|
||||
actorUserId: number
|
||||
): Promise<MtlsAccessRule> {
|
||||
const existing = await db.query.mtlsAccessRules.findFirst({
|
||||
@@ -122,10 +122,10 @@ export async function updateMtlsAccessRule(
|
||||
const now = nowIso();
|
||||
const updates: Partial<typeof mtlsAccessRules.$inferInsert> = { updatedAt: now };
|
||||
|
||||
if (input.path_pattern !== undefined) updates.pathPattern = input.path_pattern.trim();
|
||||
if (input.allowed_role_ids !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowed_role_ids);
|
||||
if (input.allowed_cert_ids !== undefined) updates.allowedCertIds = JSON.stringify(input.allowed_cert_ids);
|
||||
if (input.deny_all !== undefined) updates.denyAll = input.deny_all;
|
||||
if (input.pathPattern !== undefined) updates.pathPattern = input.pathPattern.trim();
|
||||
if (input.allowedRoleIds !== undefined) updates.allowedRoleIds = JSON.stringify(input.allowedRoleIds);
|
||||
if (input.allowedCertIds !== undefined) updates.allowedCertIds = JSON.stringify(input.allowedCertIds);
|
||||
if (input.denyAll !== undefined) updates.denyAll = input.denyAll;
|
||||
if (input.priority !== undefined) updates.priority = input.priority;
|
||||
if (input.description !== undefined) updates.description = input.description ?? null;
|
||||
|
||||
@@ -136,7 +136,7 @@ export async function updateMtlsAccessRule(
|
||||
action: "update",
|
||||
entityType: "mtls_access_rule",
|
||||
entityId: id,
|
||||
summary: `Updated mTLS access rule for path ${input.path_pattern ?? existing.pathPattern}`,
|
||||
summary: `Updated mTLS access rule for path ${input.pathPattern ?? existing.pathPattern}`,
|
||||
});
|
||||
|
||||
await applyCaddyConfig();
|
||||
@@ -183,10 +183,10 @@ export async function getAccessRulesForHosts(
|
||||
const map = new Map<number, MtlsAccessRule[]>();
|
||||
for (const row of rows) {
|
||||
const parsed = toMtlsAccessRule(row);
|
||||
let bucket = map.get(parsed.proxy_host_id);
|
||||
let bucket = map.get(parsed.proxyHostId);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
map.set(parsed.proxy_host_id, bucket);
|
||||
map.set(parsed.proxyHostId, bucket);
|
||||
}
|
||||
bucket.push(parsed);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@ export type MtlsRole = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
certificate_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
certificateCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type MtlsRoleInput = {
|
||||
@@ -26,7 +26,7 @@ export type MtlsRoleInput = {
|
||||
};
|
||||
|
||||
export type MtlsRoleWithCertificates = MtlsRole & {
|
||||
certificate_ids: number[];
|
||||
certificateIds: number[];
|
||||
};
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
@@ -46,9 +46,9 @@ function toMtlsRole(row: RoleRow, certCount: number): MtlsRole {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
certificate_count: certCount,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
certificateCount: certCount,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function getMtlsRole(id: number): Promise<MtlsRoleWithCertificates
|
||||
|
||||
return {
|
||||
...toMtlsRole(row, assignments.length),
|
||||
certificate_ids: assignments.map((a) => a.certId),
|
||||
certificateIds: assignments.map((a) => a.certId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
181
src/lib/models/oauth-providers.ts
Normal file
181
src/lib/models/oauth-providers.ts
Normal 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 }));
|
||||
}
|
||||
@@ -279,60 +279,60 @@ export type ProxyHost = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id: number | null;
|
||||
access_list_id: number | null;
|
||||
ssl_forced: boolean;
|
||||
hsts_enabled: boolean;
|
||||
hsts_subdomains: boolean;
|
||||
allow_websocket: boolean;
|
||||
preserve_host_header: boolean;
|
||||
skip_https_hostname_validation: boolean;
|
||||
certificateId: number | null;
|
||||
accessListId: number | null;
|
||||
sslForced: boolean;
|
||||
hstsEnabled: boolean;
|
||||
hstsSubdomains: boolean;
|
||||
allowWebsocket: boolean;
|
||||
preserveHostHeader: boolean;
|
||||
skipHttpsHostnameValidation: boolean;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
custom_reverse_proxy_json: string | null;
|
||||
custom_pre_handlers_json: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
customReverseProxyJson: string | null;
|
||||
customPreHandlersJson: string | null;
|
||||
authentik: ProxyHostAuthentikConfig | null;
|
||||
load_balancer: LoadBalancerConfig | null;
|
||||
dns_resolver: DnsResolverConfig | null;
|
||||
upstream_dns_resolution: UpstreamDnsResolutionConfig | null;
|
||||
loadBalancer: LoadBalancerConfig | null;
|
||||
dnsResolver: DnsResolverConfig | null;
|
||||
upstreamDnsResolution: UpstreamDnsResolutionConfig | null;
|
||||
geoblock: GeoBlockSettings | null;
|
||||
geoblock_mode: GeoBlockMode;
|
||||
geoblockMode: GeoBlockMode;
|
||||
waf: WafHostConfig | null;
|
||||
mtls: MtlsConfig | null;
|
||||
cpm_forward_auth: CpmForwardAuthConfig | null;
|
||||
cpmForwardAuth: CpmForwardAuthConfig | null;
|
||||
redirects: RedirectRule[];
|
||||
rewrite: RewriteConfig | null;
|
||||
location_rules: LocationRule[];
|
||||
locationRules: LocationRule[];
|
||||
};
|
||||
|
||||
export type ProxyHostInput = {
|
||||
name: string;
|
||||
domains: string[];
|
||||
upstreams: string[];
|
||||
certificate_id?: number | null;
|
||||
access_list_id?: number | null;
|
||||
ssl_forced?: boolean;
|
||||
hsts_enabled?: boolean;
|
||||
hsts_subdomains?: boolean;
|
||||
allow_websocket?: boolean;
|
||||
preserve_host_header?: boolean;
|
||||
skip_https_hostname_validation?: boolean;
|
||||
certificateId?: number | null;
|
||||
accessListId?: number | null;
|
||||
sslForced?: boolean;
|
||||
hstsEnabled?: boolean;
|
||||
hstsSubdomains?: boolean;
|
||||
allowWebsocket?: boolean;
|
||||
preserveHostHeader?: boolean;
|
||||
skipHttpsHostnameValidation?: boolean;
|
||||
enabled?: boolean;
|
||||
custom_reverse_proxy_json?: string | null;
|
||||
custom_pre_handlers_json?: string | null;
|
||||
customReverseProxyJson?: string | null;
|
||||
customPreHandlersJson?: string | null;
|
||||
authentik?: ProxyHostAuthentikInput | null;
|
||||
load_balancer?: LoadBalancerInput | null;
|
||||
dns_resolver?: DnsResolverInput | null;
|
||||
upstream_dns_resolution?: UpstreamDnsResolutionInput | null;
|
||||
loadBalancer?: LoadBalancerInput | null;
|
||||
dnsResolver?: DnsResolverInput | null;
|
||||
upstreamDnsResolution?: UpstreamDnsResolutionInput | null;
|
||||
geoblock?: GeoBlockSettings | null;
|
||||
geoblock_mode?: GeoBlockMode;
|
||||
geoblockMode?: GeoBlockMode;
|
||||
waf?: WafHostConfig | null;
|
||||
mtls?: MtlsConfig | null;
|
||||
cpm_forward_auth?: CpmForwardAuthInput | null;
|
||||
cpmForwardAuth?: CpmForwardAuthInput | null;
|
||||
redirects?: RedirectRule[] | null;
|
||||
rewrite?: RewriteConfig | null;
|
||||
location_rules?: LocationRule[] | null;
|
||||
locationRules?: LocationRule[] | null;
|
||||
};
|
||||
|
||||
type ProxyHostRow = typeof proxyHosts.$inferSelect;
|
||||
@@ -1105,8 +1105,8 @@ function normalizeUpstreamDnsResolutionInput(
|
||||
function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): string | null {
|
||||
const next: ProxyHostMeta = { ...existing };
|
||||
|
||||
if (input.custom_reverse_proxy_json !== undefined) {
|
||||
const reverse = normalizeMetaValue(input.custom_reverse_proxy_json ?? null);
|
||||
if (input.customReverseProxyJson !== undefined) {
|
||||
const reverse = normalizeMetaValue(input.customReverseProxyJson ?? null);
|
||||
if (reverse) {
|
||||
next.custom_reverse_proxy_json = reverse;
|
||||
} else {
|
||||
@@ -1114,8 +1114,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.custom_pre_handlers_json !== undefined) {
|
||||
const pre = normalizeMetaValue(input.custom_pre_handlers_json ?? null);
|
||||
if (input.customPreHandlersJson !== undefined) {
|
||||
const pre = normalizeMetaValue(input.customPreHandlersJson ?? null);
|
||||
if (pre) {
|
||||
next.custom_pre_handlers_json = pre;
|
||||
} else {
|
||||
@@ -1132,8 +1132,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.load_balancer !== undefined) {
|
||||
const loadBalancer = normalizeLoadBalancerInput(input.load_balancer, existing.load_balancer);
|
||||
if (input.loadBalancer !== undefined) {
|
||||
const loadBalancer = normalizeLoadBalancerInput(input.loadBalancer, existing.load_balancer);
|
||||
if (loadBalancer) {
|
||||
next.load_balancer = loadBalancer;
|
||||
} else {
|
||||
@@ -1141,8 +1141,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.dns_resolver !== undefined) {
|
||||
const dnsResolver = normalizeDnsResolverInput(input.dns_resolver, existing.dns_resolver);
|
||||
if (input.dnsResolver !== undefined) {
|
||||
const dnsResolver = normalizeDnsResolverInput(input.dnsResolver, existing.dns_resolver);
|
||||
if (dnsResolver) {
|
||||
next.dns_resolver = dnsResolver;
|
||||
} else {
|
||||
@@ -1150,9 +1150,9 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.upstream_dns_resolution !== undefined) {
|
||||
if (input.upstreamDnsResolution !== undefined) {
|
||||
const upstreamDnsResolution = normalizeUpstreamDnsResolutionInput(
|
||||
input.upstream_dns_resolution,
|
||||
input.upstreamDnsResolution,
|
||||
existing.upstream_dns_resolution
|
||||
);
|
||||
if (upstreamDnsResolution) {
|
||||
@@ -1172,8 +1172,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.geoblock_mode !== undefined) {
|
||||
next.geoblock_mode = input.geoblock_mode;
|
||||
if (input.geoblockMode !== undefined) {
|
||||
next.geoblock_mode = input.geoblockMode;
|
||||
}
|
||||
|
||||
if (input.waf !== undefined) {
|
||||
@@ -1192,11 +1192,11 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.cpm_forward_auth !== undefined) {
|
||||
if (input.cpm_forward_auth && input.cpm_forward_auth.enabled) {
|
||||
if (input.cpmForwardAuth !== undefined) {
|
||||
if (input.cpmForwardAuth && input.cpmForwardAuth.enabled) {
|
||||
const cfa: CpmForwardAuthMeta = { enabled: true };
|
||||
if (input.cpm_forward_auth.protected_paths && input.cpm_forward_auth.protected_paths.length > 0) {
|
||||
cfa.protected_paths = input.cpm_forward_auth.protected_paths;
|
||||
if (input.cpmForwardAuth.protected_paths && input.cpmForwardAuth.protected_paths.length > 0) {
|
||||
cfa.protected_paths = input.cpmForwardAuth.protected_paths;
|
||||
}
|
||||
next.cpm_forward_auth = cfa;
|
||||
} else {
|
||||
@@ -1222,8 +1222,8 @@ function buildMeta(existing: ProxyHostMeta, input: Partial<ProxyHostInput>): str
|
||||
}
|
||||
}
|
||||
|
||||
if (input.location_rules !== undefined) {
|
||||
const rules = sanitizeLocationRules(input.location_rules ?? []);
|
||||
if (input.locationRules !== undefined) {
|
||||
const rules = sanitizeLocationRules(input.locationRules ?? []);
|
||||
if (rules.length > 0) {
|
||||
next.location_rules = rules;
|
||||
} else {
|
||||
@@ -1537,33 +1537,33 @@ function parseProxyHost(row: ProxyHostRow): ProxyHost {
|
||||
name: row.name,
|
||||
domains: JSON.parse(row.domains),
|
||||
upstreams: JSON.parse(row.upstreams),
|
||||
certificate_id: row.certificateId ?? null,
|
||||
access_list_id: row.accessListId ?? null,
|
||||
ssl_forced: row.sslForced,
|
||||
hsts_enabled: row.hstsEnabled,
|
||||
hsts_subdomains: row.hstsSubdomains,
|
||||
allow_websocket: row.allowWebsocket,
|
||||
preserve_host_header: row.preserveHostHeader,
|
||||
skip_https_hostname_validation: row.skipHttpsHostnameValidation,
|
||||
certificateId: row.certificateId ?? null,
|
||||
accessListId: row.accessListId ?? null,
|
||||
sslForced: row.sslForced,
|
||||
hstsEnabled: row.hstsEnabled,
|
||||
hstsSubdomains: row.hstsSubdomains,
|
||||
allowWebsocket: row.allowWebsocket,
|
||||
preserveHostHeader: row.preserveHostHeader,
|
||||
skipHttpsHostnameValidation: row.skipHttpsHostnameValidation,
|
||||
enabled: row.enabled,
|
||||
created_at: toIso(row.createdAt)!,
|
||||
updated_at: toIso(row.updatedAt)!,
|
||||
custom_reverse_proxy_json: meta.custom_reverse_proxy_json ?? null,
|
||||
custom_pre_handlers_json: meta.custom_pre_handlers_json ?? null,
|
||||
createdAt: toIso(row.createdAt)!,
|
||||
updatedAt: toIso(row.updatedAt)!,
|
||||
customReverseProxyJson: meta.custom_reverse_proxy_json ?? null,
|
||||
customPreHandlersJson: meta.custom_pre_handlers_json ?? null,
|
||||
authentik: hydrateAuthentik(meta.authentik),
|
||||
load_balancer: hydrateLoadBalancer(meta.load_balancer),
|
||||
dns_resolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstream_dns_resolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
loadBalancer: hydrateLoadBalancer(meta.load_balancer),
|
||||
dnsResolver: hydrateDnsResolver(meta.dns_resolver),
|
||||
upstreamDnsResolution: hydrateUpstreamDnsResolution(meta.upstream_dns_resolution),
|
||||
geoblock: hydrateGeoBlock(meta.geoblock),
|
||||
geoblock_mode: meta.geoblock_mode ?? "merge",
|
||||
geoblockMode: meta.geoblock_mode ?? "merge",
|
||||
waf: meta.waf ?? null,
|
||||
mtls: meta.mtls ?? null,
|
||||
cpm_forward_auth: meta.cpm_forward_auth?.enabled
|
||||
cpmForwardAuth: meta.cpm_forward_auth?.enabled
|
||||
? { enabled: true, protected_paths: meta.cpm_forward_auth.protected_paths ?? null }
|
||||
: null,
|
||||
redirects: meta.redirects ?? [],
|
||||
rewrite: meta.rewrite ?? null,
|
||||
location_rules: meta.location_rules ?? [],
|
||||
locationRules: meta.location_rules ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1590,7 +1590,7 @@ const PROXY_HOST_SORT_COLUMNS: Record<string, any> = {
|
||||
domains: proxyHosts.domains,
|
||||
upstreams: proxyHosts.upstreams,
|
||||
enabled: proxyHosts.enabled,
|
||||
created_at: proxyHosts.createdAt,
|
||||
createdAt: proxyHosts.createdAt,
|
||||
};
|
||||
|
||||
export async function listProxyHostsPaginated(
|
||||
@@ -1635,16 +1635,16 @@ export async function createProxyHost(input: ProxyHostInput, actorUserId: number
|
||||
name: input.name.trim(),
|
||||
domains: JSON.stringify(domains),
|
||||
upstreams: JSON.stringify(Array.from(new Set(input.upstreams.map((u) => u.trim())))),
|
||||
certificateId: input.certificate_id ?? null,
|
||||
accessListId: input.access_list_id ?? null,
|
||||
certificateId: input.certificateId ?? null,
|
||||
accessListId: input.accessListId ?? null,
|
||||
ownerUserId: actorUserId,
|
||||
sslForced: input.ssl_forced ?? true,
|
||||
hstsEnabled: input.hsts_enabled ?? true,
|
||||
hstsSubdomains: input.hsts_subdomains ?? false,
|
||||
allowWebsocket: input.allow_websocket ?? true,
|
||||
preserveHostHeader: input.preserve_host_header ?? true,
|
||||
sslForced: input.sslForced ?? true,
|
||||
hstsEnabled: input.hstsEnabled ?? true,
|
||||
hstsSubdomains: input.hstsSubdomains ?? false,
|
||||
allowWebsocket: input.allowWebsocket ?? true,
|
||||
preserveHostHeader: input.preserveHostHeader ?? true,
|
||||
meta,
|
||||
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? false,
|
||||
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? false,
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -1689,20 +1689,20 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
}
|
||||
const upstreams = input.upstreams ? JSON.stringify(Array.from(new Set(input.upstreams))) : JSON.stringify(existing.upstreams);
|
||||
const existingMeta: ProxyHostMeta = {
|
||||
custom_reverse_proxy_json: existing.custom_reverse_proxy_json ?? undefined,
|
||||
custom_pre_handlers_json: existing.custom_pre_handlers_json ?? undefined,
|
||||
custom_reverse_proxy_json: existing.customReverseProxyJson ?? undefined,
|
||||
custom_pre_handlers_json: existing.customPreHandlersJson ?? undefined,
|
||||
authentik: dehydrateAuthentik(existing.authentik),
|
||||
load_balancer: dehydrateLoadBalancer(existing.load_balancer),
|
||||
dns_resolver: dehydrateDnsResolver(existing.dns_resolver),
|
||||
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstream_dns_resolution),
|
||||
load_balancer: dehydrateLoadBalancer(existing.loadBalancer),
|
||||
dns_resolver: dehydrateDnsResolver(existing.dnsResolver),
|
||||
upstream_dns_resolution: dehydrateUpstreamDnsResolution(existing.upstreamDnsResolution),
|
||||
geoblock: dehydrateGeoBlock(existing.geoblock),
|
||||
...(existing.geoblock_mode !== "merge" ? { geoblock_mode: existing.geoblock_mode } : {}),
|
||||
...(existing.geoblockMode !== "merge" ? { geoblock_mode: existing.geoblockMode } : {}),
|
||||
...(existing.waf ? { waf: existing.waf } : {}),
|
||||
...(existing.mtls ? { mtls: existing.mtls } : {}),
|
||||
...(existing.cpm_forward_auth?.enabled ? {
|
||||
...(existing.cpmForwardAuth?.enabled ? {
|
||||
cpm_forward_auth: {
|
||||
enabled: true,
|
||||
...(existing.cpm_forward_auth.protected_paths ? { protected_paths: existing.cpm_forward_auth.protected_paths } : {})
|
||||
...(existing.cpmForwardAuth.protected_paths ? { protected_paths: existing.cpmForwardAuth.protected_paths } : {})
|
||||
}
|
||||
} : {}),
|
||||
};
|
||||
@@ -1715,15 +1715,15 @@ export async function updateProxyHost(id: number, input: Partial<ProxyHostInput>
|
||||
name: input.name ?? existing.name,
|
||||
domains,
|
||||
upstreams,
|
||||
certificateId: input.certificate_id !== undefined ? input.certificate_id : existing.certificate_id,
|
||||
accessListId: input.access_list_id !== undefined ? input.access_list_id : existing.access_list_id,
|
||||
sslForced: input.ssl_forced ?? existing.ssl_forced,
|
||||
hstsEnabled: input.hsts_enabled ?? existing.hsts_enabled,
|
||||
hstsSubdomains: input.hsts_subdomains ?? existing.hsts_subdomains,
|
||||
allowWebsocket: input.allow_websocket ?? existing.allow_websocket,
|
||||
preserveHostHeader: input.preserve_host_header ?? existing.preserve_host_header,
|
||||
certificateId: input.certificateId !== undefined ? input.certificateId : existing.certificateId,
|
||||
accessListId: input.accessListId !== undefined ? input.accessListId : existing.accessListId,
|
||||
sslForced: input.sslForced ?? existing.sslForced,
|
||||
hstsEnabled: input.hstsEnabled ?? existing.hstsEnabled,
|
||||
hstsSubdomains: input.hstsSubdomains ?? existing.hstsSubdomains,
|
||||
allowWebsocket: input.allowWebsocket ?? existing.allowWebsocket,
|
||||
preserveHostHeader: input.preserveHostHeader ?? existing.preserveHostHeader,
|
||||
meta,
|
||||
skipHttpsHostnameValidation: input.skip_https_hostname_validation ?? existing.skip_https_hostname_validation,
|
||||
skipHttpsHostnameValidation: input.skipHttpsHostnameValidation ?? existing.skipHttpsHostnameValidation,
|
||||
enabled: input.enabled ?? existing.enabled,
|
||||
updatedAt: now
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import db, { nowIso, toIso } from "../db";
|
||||
import { users } from "../db/schema";
|
||||
import { users, accounts } from "../db/schema";
|
||||
import { and, count, eq } from "drizzle-orm";
|
||||
import { deleteUserForwardAuthSessions } from "./forward-auth";
|
||||
|
||||
@@ -7,14 +7,14 @@ export type User = {
|
||||
id: number;
|
||||
email: string;
|
||||
name: string | null;
|
||||
password_hash: string | null;
|
||||
passwordHash: string | null;
|
||||
role: "admin" | "user" | "viewer";
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url: string | null;
|
||||
provider: string | null;
|
||||
subject: string | null;
|
||||
avatarUrl: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
type DbUser = typeof users.$inferSelect;
|
||||
@@ -24,14 +24,14 @@ function parseDbUser(user: DbUser): User {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
password_hash: user.passwordHash,
|
||||
passwordHash: user.passwordHash,
|
||||
role: user.role as "admin" | "user" | "viewer",
|
||||
provider: user.provider,
|
||||
subject: user.subject,
|
||||
avatar_url: user.avatarUrl,
|
||||
avatarUrl: user.avatarUrl,
|
||||
status: user.status,
|
||||
created_at: toIso(user.createdAt)!,
|
||||
updated_at: toIso(user.updatedAt)!
|
||||
createdAt: toIso(user.createdAt)!,
|
||||
updatedAt: toIso(user.updatedAt)!
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,8 +48,14 @@ export async function getUserCount(): Promise<number> {
|
||||
}
|
||||
|
||||
export async function findUserByProviderSubject(provider: string, subject: string): Promise<User | null> {
|
||||
const account = await db.select().from(accounts).where(
|
||||
and(eq(accounts.providerId, provider), eq(accounts.accountId, subject))
|
||||
).limit(1);
|
||||
|
||||
if (account.length === 0) return null;
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (table, operators) => and(operators.eq(table.provider, provider), operators.eq(table.subject, subject))
|
||||
where: (table, { eq }) => eq(table.id, account[0].userId)
|
||||
});
|
||||
return user ? parseDbUser(user) : null;
|
||||
}
|
||||
@@ -68,7 +74,7 @@ export async function createUser(data: {
|
||||
role?: User["role"];
|
||||
provider: string;
|
||||
subject: string;
|
||||
avatar_url?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
passwordHash?: string | null;
|
||||
}): Promise<User> {
|
||||
const now = nowIso();
|
||||
@@ -84,7 +90,7 @@ export async function createUser(data: {
|
||||
role,
|
||||
provider: data.provider,
|
||||
subject: data.subject,
|
||||
avatarUrl: data.avatar_url ?? null,
|
||||
avatarUrl: data.avatarUrl ?? null,
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
@@ -94,7 +100,7 @@ export async function createUser(data: {
|
||||
return parseDbUser(user);
|
||||
}
|
||||
|
||||
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatar_url?: string | null }): Promise<User | null> {
|
||||
export async function updateUserProfile(userId: number, data: { email?: string; name?: string | null; avatarUrl?: string | null }): Promise<User | null> {
|
||||
const current = await getUserById(userId);
|
||||
if (!current) {
|
||||
return null;
|
||||
@@ -106,7 +112,7 @@ export async function updateUserProfile(userId: number, data: { email?: string;
|
||||
.set({
|
||||
email: data.email ?? current.email,
|
||||
name: data.name ?? current.name,
|
||||
avatarUrl: data.avatar_url ?? current.avatar_url,
|
||||
avatarUrl: data.avatarUrl ?? current.avatarUrl,
|
||||
updatedAt: now
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { randomBytes, randomUUID } from "crypto";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { config } from "../config";
|
||||
import { findUserByEmail, findUserByProviderSubject, getUserById } from "../models/user";
|
||||
import { findUserByEmail, getUserById } from "../models/user";
|
||||
import db from "../db";
|
||||
import { users, linkingTokens } from "../db/schema";
|
||||
import { eq, lt } from "drizzle-orm";
|
||||
import { users, linkingTokens, accounts } from "../db/schema";
|
||||
import { and, eq, lt } from "drizzle-orm";
|
||||
import { nowIso } from "../db";
|
||||
|
||||
const LINKING_TOKEN_EXPIRY = 5 * 60; // 5 minutes in seconds
|
||||
@@ -32,14 +32,13 @@ export async function decideLinkingStrategy(
|
||||
providerAccountId: string,
|
||||
email: string
|
||||
): Promise<LinkingDecision> {
|
||||
// Check if OAuth account already exists
|
||||
const existingOAuthUser = await findUserByProviderSubject(provider, providerAccountId);
|
||||
if (existingOAuthUser) {
|
||||
return {
|
||||
action: "signin_existing",
|
||||
userId: existingOAuthUser.id,
|
||||
reason: "OAuth account already linked"
|
||||
};
|
||||
// Check accounts table for existing OAuth connection
|
||||
const existingAccount = await db.select().from(accounts).where(
|
||||
and(eq(accounts.providerId, provider), eq(accounts.accountId, providerAccountId))
|
||||
).limit(1);
|
||||
|
||||
if (existingAccount.length > 0) {
|
||||
return { action: "signin_existing", userId: existingAccount[0].userId, reason: "OAuth account already linked" };
|
||||
}
|
||||
|
||||
// Check if email matches existing user
|
||||
@@ -52,7 +51,7 @@ export async function decideLinkingStrategy(
|
||||
}
|
||||
|
||||
// User exists with this email
|
||||
if (existingEmailUser.password_hash) {
|
||||
if (existingEmailUser.passwordHash) {
|
||||
// Has password - require manual linking with password verification
|
||||
return {
|
||||
action: "require_manual_link",
|
||||
@@ -188,25 +187,24 @@ export async function verifyAndLinkOAuth(
|
||||
providerAccountId: string
|
||||
): Promise<boolean> {
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.password_hash) {
|
||||
if (!user || !user.passwordHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = bcrypt.compareSync(password, user.password_hash);
|
||||
const isValid = bcrypt.compareSync(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -227,20 +225,26 @@ export async function autoLinkOAuth(
|
||||
|
||||
// Don't auto-link if user has a password (unless explicitly called for authenticated linking)
|
||||
// This check is bypassed when called from the authenticated linking flow
|
||||
if (user.password_hash && !config.oauth.allowAutoLinking) {
|
||||
if (user.passwordHash && !config.oauth.allowAutoLinking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
// Update avatar if provided
|
||||
if (avatarUrl) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ avatarUrl, updatedAt: nowIso() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -260,16 +264,22 @@ export async function linkOAuthAuthenticated(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update user to link OAuth
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
provider,
|
||||
subject: providerAccountId,
|
||||
avatarUrl: avatarUrl ?? user.avatar_url,
|
||||
updatedAt: nowIso()
|
||||
})
|
||||
.where(eq(users.id, userId));
|
||||
// Insert OAuth account link
|
||||
await db.insert(accounts).values({
|
||||
userId,
|
||||
accountId: providerAccountId,
|
||||
providerId: provider,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso()
|
||||
});
|
||||
|
||||
// Update avatar if provided
|
||||
if (avatarUrl) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ avatarUrl, updatedAt: nowIso() })
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
44
src/lib/services/oauth-provider-sync.ts
Normal file
44
src/lib/services/oauth-provider-sync.ts
Normal 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
|
||||
}
|
||||
@@ -18,7 +18,7 @@ staticClients:
|
||||
secret: cpm-test-secret
|
||||
name: "CPM E2E Test"
|
||||
redirectURIs:
|
||||
- "http://localhost:3000/api/auth/callback/oauth2"
|
||||
- "http://localhost:3000/api/auth/oauth2/callback/dex"
|
||||
|
||||
enablePasswordDB: true
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ services:
|
||||
OAUTH_TOKEN_URL: http://dex:5556/dex/token
|
||||
OAUTH_USERINFO_URL: http://dex:5556/dex/userinfo
|
||||
OAUTH_ALLOW_AUTO_LINKING: "true"
|
||||
# Disable Better Auth rate limiting for E2E tests (many rapid auth requests)
|
||||
AUTH_RATE_LIMIT_ENABLED: "false"
|
||||
clickhouse:
|
||||
environment:
|
||||
CLICKHOUSE_PASSWORD: "test-clickhouse-password-2026"
|
||||
|
||||
@@ -54,17 +54,30 @@ async function apiGet(page: Page, path: string) {
|
||||
return page.request.get(`${API}${path}`);
|
||||
}
|
||||
|
||||
/** Log into Dex with email/password. Handles the Dex login form. */
|
||||
/** Log into Dex with email/password. Handles the Dex login form.
|
||||
* If Dex has an existing session and auto-redirects, this is a no-op. */
|
||||
async function dexLogin(page: Page, email: string, password: string) {
|
||||
// Wait for either Dex login form OR auto-redirect back to our app.
|
||||
// Dex may auto-redirect if it has an active session from a prior login.
|
||||
try {
|
||||
await page.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
|
||||
} catch {
|
||||
// Already redirected back — no Dex login needed (Dex has existing session)
|
||||
return;
|
||||
}
|
||||
|
||||
// Dex shows a "Log in to dex" page with a link to the local (password) connector
|
||||
// or goes straight to the login form
|
||||
const loginLink = page.getByRole('link', { name: /log in with email/i });
|
||||
if (await loginLink.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await loginLink.click();
|
||||
}
|
||||
|
||||
// If Dex auto-redirected during the wait above, skip the form
|
||||
if (!page.url().includes('localhost:5556')) return;
|
||||
|
||||
// Wait for the Dex login form to appear
|
||||
await expect(page.getByRole('button', { name: /login/i })).toBeVisible({ timeout: 10_000 });
|
||||
// Dex uses "email address" and "Password" as accessible names
|
||||
await page.getByRole('textbox', { name: /email/i }).fill(email);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(password);
|
||||
await page.getByRole('button', { name: /login/i }).click();
|
||||
@@ -75,6 +88,46 @@ async function freshContext(page: Page): Promise<BrowserContext> {
|
||||
return page.context().browser()!.newContext({ storageState: { cookies: [], origins: [] } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an OAuth login through the /login page and verify the user was created.
|
||||
* Uses a fresh browser context to avoid session conflicts between users.
|
||||
* Retries once on failure (Better Auth OAuth state can race between rapid logins).
|
||||
*/
|
||||
async function doOAuthLogin(page: Page, user: { email: string; password: string }) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle' });
|
||||
console.log(`[doOAuthLogin] ${user.email} on: ${p.url()}`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
// Wait for navigation to Dex
|
||||
await p.waitForURL((url) => url.toString().includes('localhost:5556'), { timeout: 15_000 });
|
||||
console.log(`[doOAuthLogin] ${user.email} after nav: ${p.url()}`);
|
||||
await dexLogin(p, user.email, user.password);
|
||||
// Wait for redirect back to the app
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
|
||||
// Verify the URL doesn't indicate an error
|
||||
const finalUrl = p.url();
|
||||
if (finalUrl.includes('error=') || finalUrl.includes('/login')) {
|
||||
if (attempt === 0) continue; // retry
|
||||
throw new Error(`OAuth login failed for ${user.email}: ${finalUrl}`);
|
||||
}
|
||||
return; // success
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform OAuth login on the portal and return the callback URL.
|
||||
* Does NOT navigate to the callback (test domains aren't DNS-resolvable).
|
||||
@@ -149,8 +202,8 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
|
||||
name: 'OAuth Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
sslForced: false,
|
||||
cpmForwardAuth: { enabled: true },
|
||||
});
|
||||
expect(res.status()).toBe(201);
|
||||
const host = await res.json();
|
||||
@@ -159,43 +212,11 @@ test.describe.serial('Forward Auth with OAuth (Dex)', () => {
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for alice to create her user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, ALICE.email, ALICE.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
await doOAuthLogin(page, ALICE);
|
||||
});
|
||||
|
||||
test('setup: trigger OAuth login for bob to create his user account', async ({ page }) => {
|
||||
const ctx = await freshContext(page);
|
||||
const p = await ctx.newPage();
|
||||
try {
|
||||
await p.goto(`${BASE_URL}/login`);
|
||||
const oauthButton = p.getByRole('button', { name: /continue with|sign in with/i });
|
||||
await expect(oauthButton).toBeVisible({ timeout: 10_000 });
|
||||
await oauthButton.click();
|
||||
await dexLogin(p, BOB.email, BOB.password);
|
||||
await p.waitForURL((url) => {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.origin === BASE_URL && !u.pathname.startsWith('/api/auth');
|
||||
} catch { return false; }
|
||||
}, { timeout: 30_000 });
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
await doOAuthLogin(page, BOB);
|
||||
});
|
||||
|
||||
test('setup: find alice and bob user IDs', async ({ page }) => {
|
||||
|
||||
@@ -28,8 +28,8 @@ test.describe.serial('Forward Auth', () => {
|
||||
name: 'Functional Forward Auth Test',
|
||||
domains: [DOMAIN],
|
||||
upstreams: ['echo-server:8080'],
|
||||
ssl_forced: false,
|
||||
cpm_forward_auth: { enabled: true },
|
||||
sslForced: false,
|
||||
cpmForwardAuth: { enabled: true },
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', 'Origin': BASE_URL },
|
||||
});
|
||||
|
||||
@@ -61,7 +61,7 @@ test.describe('L4 Proxy Hosts page', () => {
|
||||
await expect(sortBtn).toBeVisible();
|
||||
|
||||
await sortBtn.click();
|
||||
await expect(page).toHaveURL(/sortBy=listen_address/);
|
||||
await expect(page).toHaveURL(/sortBy=listenAddress/);
|
||||
});
|
||||
|
||||
test('creates a new L4 proxy host', async ({ page }) => {
|
||||
|
||||
@@ -58,13 +58,25 @@ function ensureTestUser(username: string, password: string, role: string) {
|
||||
const now = new Date().toISOString();
|
||||
const existing = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
||||
if (existing) {
|
||||
db.run("UPDATE users SET password_hash = ?, role = ?, status = 'active', updated_at = ? WHERE email = ?",
|
||||
db.run("UPDATE users SET passwordHash = ?, role = ?, status = 'active', updatedAt = ? WHERE email = ?",
|
||||
[hash, "${role}", now, email]);
|
||||
// Update or create credential account for Better Auth
|
||||
const acc = db.query("SELECT id FROM accounts WHERE userId = ? AND providerId = 'credential'").get(existing.id);
|
||||
if (acc) {
|
||||
db.run("UPDATE accounts SET password = ?, updatedAt = ? WHERE id = ?", [hash, now, acc.id]);
|
||||
} else {
|
||||
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
||||
[existing.id, String(existing.id), hash, now, now]);
|
||||
}
|
||||
} else {
|
||||
db.run(
|
||||
"INSERT INTO users (email, name, password_hash, role, provider, subject, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'credentials', ?, 'active', ?, ?)",
|
||||
[email, "${username}", hash, "${role}", "${username}", now, now]
|
||||
"INSERT INTO users (email, name, passwordHash, role, provider, subject, username, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, 'credentials', ?, ?, 'active', ?, ?)",
|
||||
[email, "${username}", hash, "${role}", "${username}", "${username}", now, now]
|
||||
);
|
||||
const user = db.query("SELECT id FROM users WHERE email = ?").get(email);
|
||||
// Create credential account for Better Auth
|
||||
db.run("INSERT INTO accounts (userId, accountId, providerId, password, createdAt, updatedAt) VALUES (?, ?, 'credential', ?, ?, ?)",
|
||||
[user.id, String(user.id), hash, now, now]);
|
||||
}
|
||||
`;
|
||||
execFileSync('docker', [...COMPOSE_ARGS, 'exec', '-T', 'web', 'bun', '-e', script], {
|
||||
@@ -90,7 +102,7 @@ async function loginAs(
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
|
||||
// The login client does router.replace('/') on success — wait for that
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 30_000 });
|
||||
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 60_000 });
|
||||
await page.close();
|
||||
return context;
|
||||
}
|
||||
@@ -281,8 +293,12 @@ test.describe('Role-based access control', () => {
|
||||
|
||||
// ── Admin user — can access all pages ───────────────────────────────
|
||||
|
||||
test('admin role: all dashboard pages are accessible', async ({ browser }) => {
|
||||
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
|
||||
test('admin role: all dashboard pages are accessible', async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(90_000);
|
||||
// Use the pre-authenticated admin state from global-setup
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
||||
});
|
||||
try {
|
||||
for (const path of ALL_DASHBOARD_PAGES) {
|
||||
const page = await adminContext.newPage();
|
||||
@@ -298,7 +314,9 @@ test.describe('Role-based access control', () => {
|
||||
});
|
||||
|
||||
test('admin role: sidebar shows all nav items', async ({ browser }) => {
|
||||
const adminContext = await loginAs(browser, 'testadmin', 'TestPassword2026!');
|
||||
const adminContext = await browser.newContext({
|
||||
storageState: require('path').resolve(__dirname, '../.auth/admin.json'),
|
||||
});
|
||||
try {
|
||||
const page = await adminContext.newPage();
|
||||
await page.goto('/');
|
||||
|
||||
@@ -58,65 +58,65 @@ describe('mtls-access-rules CRUD', () => {
|
||||
it('createMtlsAccessRule creates a rule', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '/admin/*',
|
||||
allowed_role_ids: [1, 2],
|
||||
allowed_cert_ids: [10],
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/admin/*',
|
||||
allowedRoleIds: [1, 2],
|
||||
allowedCertIds: [10],
|
||||
priority: 5,
|
||||
description: 'admin only',
|
||||
}, userId);
|
||||
|
||||
expect(rule.proxy_host_id).toBe(host.id);
|
||||
expect(rule.path_pattern).toBe('/admin/*');
|
||||
expect(rule.allowed_role_ids).toEqual([1, 2]);
|
||||
expect(rule.allowed_cert_ids).toEqual([10]);
|
||||
expect(rule.proxyHostId).toBe(host.id);
|
||||
expect(rule.pathPattern).toBe('/admin/*');
|
||||
expect(rule.allowedRoleIds).toEqual([1, 2]);
|
||||
expect(rule.allowedCertIds).toEqual([10]);
|
||||
expect(rule.priority).toBe(5);
|
||||
expect(rule.description).toBe('admin only');
|
||||
expect(rule.deny_all).toBe(false);
|
||||
expect(rule.denyAll).toBe(false);
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule trims path_pattern', async () => {
|
||||
it('createMtlsAccessRule trims pathPattern', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: ' /api/* ',
|
||||
proxyHostId: host.id,
|
||||
pathPattern: ' /api/* ',
|
||||
}, userId);
|
||||
expect(rule.path_pattern).toBe('/api/*');
|
||||
expect(rule.pathPattern).toBe('/api/*');
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule defaults arrays to empty', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '*',
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '*',
|
||||
}, userId);
|
||||
expect(rule.allowed_role_ids).toEqual([]);
|
||||
expect(rule.allowed_cert_ids).toEqual([]);
|
||||
expect(rule.deny_all).toBe(false);
|
||||
expect(rule.allowedRoleIds).toEqual([]);
|
||||
expect(rule.allowedCertIds).toEqual([]);
|
||||
expect(rule.denyAll).toBe(false);
|
||||
expect(rule.priority).toBe(0);
|
||||
});
|
||||
|
||||
it('createMtlsAccessRule with deny_all', async () => {
|
||||
it('createMtlsAccessRule with denyAll', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id,
|
||||
path_pattern: '/blocked/*',
|
||||
deny_all: true,
|
||||
proxyHostId: host.id,
|
||||
pathPattern: '/blocked/*',
|
||||
denyAll: true,
|
||||
}, userId);
|
||||
expect(rule.deny_all).toBe(true);
|
||||
expect(rule.denyAll).toBe(true);
|
||||
});
|
||||
|
||||
it('listMtlsAccessRules returns rules ordered by priority desc then path asc', async () => {
|
||||
const host = await insertHost();
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/b', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/a', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host.id, path_pattern: '/c', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/b', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/a', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: host.id, pathPattern: '/c', priority: 1 }, userId);
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules).toHaveLength(3);
|
||||
expect(rules[0].path_pattern).toBe('/a'); // priority 10 (highest)
|
||||
expect(rules[1].path_pattern).toBe('/b'); // priority 1, path /b
|
||||
expect(rules[2].path_pattern).toBe('/c'); // priority 1, path /c
|
||||
expect(rules[0].pathPattern).toBe('/a'); // priority 10 (highest)
|
||||
expect(rules[1].pathPattern).toBe('/b'); // priority 1, path /b
|
||||
expect(rules[2].pathPattern).toBe('/c'); // priority 1, path /c
|
||||
});
|
||||
|
||||
it('listMtlsAccessRules returns empty array for host with no rules', async () => {
|
||||
@@ -128,24 +128,24 @@ describe('mtls-access-rules CRUD', () => {
|
||||
it('listMtlsAccessRules only returns rules for the specified host', async () => {
|
||||
const host1 = await insertHost('h1');
|
||||
const host2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: host1.id, path_pattern: '/h1' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: host2.id, path_pattern: '/h2' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: host1.id, pathPattern: '/h1' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: host2.id, pathPattern: '/h2' }, userId);
|
||||
|
||||
const rules = await listMtlsAccessRules(host1.id);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(rules[0].path_pattern).toBe('/h1');
|
||||
expect(rules[0].pathPattern).toBe('/h1');
|
||||
});
|
||||
|
||||
it('getMtlsAccessRule returns a single rule', async () => {
|
||||
const host = await insertHost();
|
||||
const created = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
}, userId);
|
||||
|
||||
const fetched = await getMtlsAccessRule(created.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.id).toBe(created.id);
|
||||
expect(fetched!.path_pattern).toBe('/test');
|
||||
expect(fetched!.pathPattern).toBe('/test');
|
||||
});
|
||||
|
||||
it('getMtlsAccessRule returns null for non-existent rule', async () => {
|
||||
@@ -155,34 +155,34 @@ describe('mtls-access-rules CRUD', () => {
|
||||
it('updateMtlsAccessRule updates fields', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/old', priority: 0,
|
||||
proxyHostId: host.id, pathPattern: '/old', priority: 0,
|
||||
}, userId);
|
||||
|
||||
const updated = await updateMtlsAccessRule(rule.id, {
|
||||
path_pattern: '/new',
|
||||
pathPattern: '/new',
|
||||
priority: 99,
|
||||
allowed_role_ids: [5],
|
||||
deny_all: true,
|
||||
allowedRoleIds: [5],
|
||||
denyAll: true,
|
||||
description: 'updated',
|
||||
}, userId);
|
||||
|
||||
expect(updated.path_pattern).toBe('/new');
|
||||
expect(updated.pathPattern).toBe('/new');
|
||||
expect(updated.priority).toBe(99);
|
||||
expect(updated.allowed_role_ids).toEqual([5]);
|
||||
expect(updated.deny_all).toBe(true);
|
||||
expect(updated.allowedRoleIds).toEqual([5]);
|
||||
expect(updated.denyAll).toBe(true);
|
||||
expect(updated.description).toBe('updated');
|
||||
});
|
||||
|
||||
it('updateMtlsAccessRule partial update leaves other fields unchanged', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
allowed_role_ids: [1], priority: 5, description: 'original',
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
allowedRoleIds: [1], priority: 5, description: 'original',
|
||||
}, userId);
|
||||
|
||||
const updated = await updateMtlsAccessRule(rule.id, { priority: 10 }, userId);
|
||||
expect(updated.path_pattern).toBe('/test');
|
||||
expect(updated.allowed_role_ids).toEqual([1]);
|
||||
expect(updated.pathPattern).toBe('/test');
|
||||
expect(updated.allowedRoleIds).toEqual([1]);
|
||||
expect(updated.description).toBe('original');
|
||||
expect(updated.priority).toBe(10);
|
||||
});
|
||||
@@ -194,7 +194,7 @@ describe('mtls-access-rules CRUD', () => {
|
||||
it('deleteMtlsAccessRule removes the rule', async () => {
|
||||
const host = await insertHost();
|
||||
const rule = await createMtlsAccessRule({
|
||||
proxy_host_id: host.id, path_pattern: '/test',
|
||||
proxyHostId: host.id, pathPattern: '/test',
|
||||
}, userId);
|
||||
|
||||
await deleteMtlsAccessRule(rule.id, 1);
|
||||
@@ -221,9 +221,9 @@ describe('getAccessRulesForHosts (bulk query)', () => {
|
||||
it('groups rules by proxy host ID', async () => {
|
||||
const h1 = await insertHost('h1');
|
||||
const h2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/b' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/c' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/b' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/c' }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h1.id, h2.id]);
|
||||
expect(map.get(h1.id)).toHaveLength(2);
|
||||
@@ -233,8 +233,8 @@ describe('getAccessRulesForHosts (bulk query)', () => {
|
||||
it('excludes hosts not in the query list', async () => {
|
||||
const h1 = await insertHost('h1');
|
||||
const h2 = await insertHost('h2');
|
||||
await createMtlsAccessRule({ proxy_host_id: h1.id, path_pattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h2.id, path_pattern: '/b' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h1.id, pathPattern: '/a' }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h2.id, pathPattern: '/b' }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h1.id]);
|
||||
expect(map.has(h1.id)).toBe(true);
|
||||
@@ -243,20 +243,20 @@ describe('getAccessRulesForHosts (bulk query)', () => {
|
||||
|
||||
it('rules within a host are ordered by priority desc, path asc', async () => {
|
||||
const h = await insertHost();
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/z', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/a', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxy_host_id: h.id, path_pattern: '/m', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/z', priority: 10 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/a', priority: 1 }, userId);
|
||||
await createMtlsAccessRule({ proxyHostId: h.id, pathPattern: '/m', priority: 10 }, userId);
|
||||
|
||||
const map = await getAccessRulesForHosts([h.id]);
|
||||
const rules = map.get(h.id)!;
|
||||
expect(rules[0].path_pattern).toBe('/m'); // priority 10, path /m
|
||||
expect(rules[1].path_pattern).toBe('/z'); // priority 10, path /z
|
||||
expect(rules[2].path_pattern).toBe('/a'); // priority 1
|
||||
expect(rules[0].pathPattern).toBe('/m'); // priority 10, path /m
|
||||
expect(rules[1].pathPattern).toBe('/z'); // priority 10, path /z
|
||||
expect(rules[2].pathPattern).toBe('/a'); // priority 1
|
||||
});
|
||||
});
|
||||
|
||||
describe('JSON parsing edge cases in access rules', () => {
|
||||
it('handles malformed allowed_role_ids JSON gracefully', async () => {
|
||||
it('handles malformed allowedRoleIds JSON gracefully', async () => {
|
||||
const host = await insertHost();
|
||||
const now = nowIso();
|
||||
// Insert directly with bad JSON
|
||||
@@ -267,7 +267,7 @@ describe('JSON parsing edge cases in access rules', () => {
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([]);
|
||||
expect(rules[0].allowedRoleIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters non-numeric values from JSON arrays', async () => {
|
||||
@@ -280,7 +280,7 @@ describe('JSON parsing edge cases in access rules', () => {
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([1, 3]);
|
||||
expect(rules[0].allowedRoleIds).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it('handles non-array JSON', async () => {
|
||||
@@ -293,7 +293,7 @@ describe('JSON parsing edge cases in access rules', () => {
|
||||
});
|
||||
|
||||
const rules = await listMtlsAccessRules(host.id);
|
||||
expect(rules[0].allowed_role_ids).toEqual([]);
|
||||
expect(rules[0].allowed_cert_ids).toEqual([]);
|
||||
expect(rules[0].allowedRoleIds).toEqual([]);
|
||||
expect(rules[0].allowedCertIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('mtls-roles model CRUD', () => {
|
||||
const role = await createMtlsRole({ name: 'admin', description: 'Admin role' }, userId);
|
||||
expect(role.name).toBe('admin');
|
||||
expect(role.description).toBe('Admin role');
|
||||
expect(role.certificate_count).toBe(0);
|
||||
expect(role.certificateCount).toBe(0);
|
||||
expect(role.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('mtls-roles model CRUD', () => {
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const roles = await listMtlsRoles();
|
||||
expect(roles[0].certificate_count).toBe(1);
|
||||
expect(roles[0].certificateCount).toBe(1);
|
||||
});
|
||||
|
||||
it('listMtlsRoles returns empty array when no roles', async () => {
|
||||
@@ -123,7 +123,7 @@ describe('mtls-roles model CRUD', () => {
|
||||
expect(roles).toEqual([]);
|
||||
});
|
||||
|
||||
it('getMtlsRole returns role with certificate_ids', async () => {
|
||||
it('getMtlsRole returns role with certificateIds', async () => {
|
||||
const { cert1, cert2 } = await seedCaAndCerts();
|
||||
const role = await createMtlsRole({ name: 'admin' }, userId);
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
@@ -131,9 +131,9 @@ describe('mtls-roles model CRUD', () => {
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.certificate_ids).toHaveLength(2);
|
||||
expect(fetched!.certificate_ids).toContain(cert1.id);
|
||||
expect(fetched!.certificate_ids).toContain(cert2.id);
|
||||
expect(fetched!.certificateIds).toHaveLength(2);
|
||||
expect(fetched!.certificateIds).toContain(cert1.id);
|
||||
expect(fetched!.certificateIds).toContain(cert2.id);
|
||||
});
|
||||
|
||||
it('getMtlsRole returns null for non-existent role', async () => {
|
||||
@@ -177,7 +177,7 @@ describe('mtls-roles certificate assignments', () => {
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toContain(cert1.id);
|
||||
expect(fetched!.certificateIds).toContain(cert1.id);
|
||||
});
|
||||
|
||||
it('assignRoleToCertificate throws for non-existent role', async () => {
|
||||
@@ -204,7 +204,7 @@ describe('mtls-roles certificate assignments', () => {
|
||||
await removeRoleFromCertificate(role.id, cert1.id, 1);
|
||||
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toEqual([]);
|
||||
expect(fetched!.certificateIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('removeRoleFromCertificate throws for non-existent role', async () => {
|
||||
@@ -247,7 +247,7 @@ describe('mtls-roles certificate assignments', () => {
|
||||
await assignRoleToCertificate(role.id, cert1.id, 1);
|
||||
await assignRoleToCertificate(role.id, cert2.id, 1);
|
||||
const fetched = await getMtlsRole(role.id);
|
||||
expect(fetched!.certificate_ids).toHaveLength(2);
|
||||
expect(fetched!.certificateIds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
147
tests/integration/oauth-provider-sync.test.ts
Normal file
147
tests/integration/oauth-provider-sync.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
205
tests/integration/oauth-providers.test.ts
Normal file
205
tests/integration/oauth-providers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ beforeEach(() => {
|
||||
describe('authenticateApiRequest', () => {
|
||||
it('authenticates via Bearer token', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
|
||||
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
|
||||
user: { id: 42, role: 'admin' },
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('authenticateApiRequest', () => {
|
||||
describe('requireApiAdmin', () => {
|
||||
it('allows admin users', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 1, created_at: '', last_used_at: null, expires_at: null },
|
||||
token: { id: 1, name: 'test', createdBy: 1, createdAt: '', lastUsedAt: null, expiresAt: null },
|
||||
user: { id: 1, role: 'admin' },
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('requireApiAdmin', () => {
|
||||
|
||||
it('rejects non-admin users with 403', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 2, created_at: '', last_used_at: null, expires_at: null },
|
||||
token: { id: 1, name: 'test', createdBy: 2, createdAt: '', lastUsedAt: null, expiresAt: null },
|
||||
user: { id: 2, role: 'user' },
|
||||
});
|
||||
|
||||
@@ -154,7 +154,7 @@ describe('requireApiUser', () => {
|
||||
|
||||
it('CSRF check skips for Bearer-authenticated POST', async () => {
|
||||
mockValidateToken.mockResolvedValue({
|
||||
token: { id: 1, name: 'test', created_by: 42, created_at: '', last_used_at: null, expires_at: null },
|
||||
token: { id: 1, name: 'test', createdBy: 42, createdAt: '', lastUsedAt: null, expiresAt: null },
|
||||
user: { id: 42, role: 'admin' },
|
||||
});
|
||||
|
||||
|
||||
@@ -53,8 +53,8 @@ const sampleUser = {
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
role: 'admin',
|
||||
password_hash: '$2b$10$hashedpassword',
|
||||
created_at: '2026-01-01',
|
||||
passwordHash: '$2b$10$hashedpassword',
|
||||
createdAt: '2026-01-01',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -64,7 +64,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('GET /api/v1/users', () => {
|
||||
it('returns list of users with password_hash stripped', async () => {
|
||||
it('returns list of users with passwordHash stripped', async () => {
|
||||
mockListUsers.mockResolvedValue([sampleUser] as any);
|
||||
|
||||
const response = await listGET(createMockRequest());
|
||||
@@ -72,7 +72,7 @@ describe('GET /api/v1/users', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0]).not.toHaveProperty('password_hash');
|
||||
expect(data[0]).not.toHaveProperty('passwordHash');
|
||||
expect(data[0].name).toBe('Admin User');
|
||||
expect(data[0].email).toBe('admin@example.com');
|
||||
});
|
||||
@@ -87,14 +87,14 @@ describe('GET /api/v1/users', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/v1/users/[id]', () => {
|
||||
it('returns a user by id with password_hash stripped', async () => {
|
||||
it('returns a user by id with passwordHash stripped', async () => {
|
||||
mockGetUserById.mockResolvedValue(sampleUser as any);
|
||||
|
||||
const response = await getGET(createMockRequest(), { params: Promise.resolve({ id: '1' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data).not.toHaveProperty('password_hash');
|
||||
expect(data).not.toHaveProperty('passwordHash');
|
||||
expect(data.name).toBe('Admin User');
|
||||
});
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('GET /api/v1/users/[id]', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.id).toBe(5);
|
||||
expect(data).not.toHaveProperty('password_hash');
|
||||
expect(data).not.toHaveProperty('passwordHash');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('PUT /api/v1/users/[id]', () => {
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.name).toBe('Updated Name');
|
||||
expect(data).not.toHaveProperty('password_hash');
|
||||
expect(data).not.toHaveProperty('passwordHash');
|
||||
expect(mockUpdateUserProfile).toHaveBeenCalledWith(1, { name: 'Updated Name' });
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
|
||||
function makeRule(overrides: Partial<MtlsAccessRuleLike> = {}): MtlsAccessRuleLike {
|
||||
return {
|
||||
path_pattern: "/admin/*",
|
||||
allowed_role_ids: [],
|
||||
allowed_cert_ids: [],
|
||||
deny_all: false,
|
||||
pathPattern: "/admin/*",
|
||||
allowedRoleIds: [],
|
||||
allowedCertIds: [],
|
||||
denyAll: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -54,7 +54,7 @@ describe("resolveAllowedFingerprints", () => {
|
||||
]);
|
||||
const certFpMap = new Map<number, string>();
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1, 2] });
|
||||
const rule = makeRule({ allowedRoleIds: [1, 2] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_a", "fp_b", "fp_c"]));
|
||||
@@ -67,7 +67,7 @@ describe("resolveAllowedFingerprints", () => {
|
||||
[20, "fp_y"],
|
||||
]);
|
||||
|
||||
const rule = makeRule({ allowed_cert_ids: [10, 20] });
|
||||
const rule = makeRule({ allowedCertIds: [10, 20] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_x", "fp_y"]));
|
||||
@@ -79,7 +79,7 @@ describe("resolveAllowedFingerprints", () => {
|
||||
]);
|
||||
const certFpMap = new Map<number, string>([[10, "fp_b"]]);
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
|
||||
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result).toEqual(new Set(["fp_a", "fp_b"]));
|
||||
@@ -91,7 +91,7 @@ describe("resolveAllowedFingerprints", () => {
|
||||
]);
|
||||
const certFpMap = new Map<number, string>([[10, "fp_a"]]);
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [1], allowed_cert_ids: [10] });
|
||||
const rule = makeRule({ allowedRoleIds: [1], allowedCertIds: [10] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
@@ -102,7 +102,7 @@ describe("resolveAllowedFingerprints", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>();
|
||||
const certFpMap = new Map<number, string>();
|
||||
|
||||
const rule = makeRule({ allowed_role_ids: [999], allowed_cert_ids: [999] });
|
||||
const rule = makeRule({ allowedRoleIds: [999], allowedCertIds: [999] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, certFpMap);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
@@ -146,7 +146,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([
|
||||
[1, new Set(["fp_admin"])],
|
||||
]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
const rules = [makeRule({ allowedRoleIds: [1] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
@@ -174,8 +174,8 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
expect(catchAll.terminal).toBe(true);
|
||||
});
|
||||
|
||||
it("generates 403 for deny_all rule", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
it("generates 403 for denyAll rule", () => {
|
||||
const rules = [makeRule({ denyAll: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -188,7 +188,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
});
|
||||
|
||||
it("generates 403 when rule has no matching fingerprints", () => {
|
||||
const rules = [makeRule({ allowed_role_ids: [999] })]; // role doesn't exist
|
||||
const rules = [makeRule({ allowedRoleIds: [999] })]; // role doesn't exist
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
@@ -206,8 +206,8 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
[2, new Set(["fp_api"])],
|
||||
]);
|
||||
const rules = [
|
||||
makeRule({ path_pattern: "/admin/*", allowed_role_ids: [1] }),
|
||||
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1, 2] }),
|
||||
makeRule({ pathPattern: "/admin/*", allowedRoleIds: [1] }),
|
||||
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1, 2] }),
|
||||
];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
@@ -219,7 +219,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
|
||||
it("uses direct cert fingerprints as overrides", () => {
|
||||
const certFpMap = new Map<number, string>([[42, "fp_special"]]);
|
||||
const rules = [makeRule({ allowed_cert_ids: [42] })];
|
||||
const rules = [makeRule({ allowedCertIds: [42] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), certFpMap, baseHandlers, reverseProxy);
|
||||
|
||||
@@ -230,7 +230,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
});
|
||||
|
||||
it("catch-all route includes base handlers + reverse proxy", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
const rules = [makeRule({ denyAll: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
const catchAll = result![result!.length - 1] as Record<string, unknown>;
|
||||
@@ -242,7 +242,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
|
||||
it("allow route includes base handlers + reverse proxy", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
const rules = [makeRule({ allowedRoleIds: [1] })];
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
const allowRoute = result![0] as Record<string, unknown>;
|
||||
@@ -252,17 +252,17 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
});
|
||||
|
||||
it("deny route body is 'mTLS access denied'", () => {
|
||||
const rules = [makeRule({ deny_all: true })];
|
||||
const rules = [makeRule({ denyAll: true })];
|
||||
const result = buildMtlsRbacSubroutes(rules, new Map(), new Map(), baseHandlers, reverseProxy);
|
||||
const denyHandler = (result![0] as any).handle[0];
|
||||
expect(denyHandler.body).toBe("mTLS access denied");
|
||||
});
|
||||
|
||||
it("handles mixed deny_all and role-based rules", () => {
|
||||
it("handles mixed denyAll and role-based rules", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [
|
||||
makeRule({ path_pattern: "/secret/*", deny_all: true }),
|
||||
makeRule({ path_pattern: "/api/*", allowed_role_ids: [1] }),
|
||||
makeRule({ pathPattern: "/secret/*", denyAll: true }),
|
||||
makeRule({ pathPattern: "/api/*", allowedRoleIds: [1] }),
|
||||
];
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), baseHandlers, reverseProxy);
|
||||
|
||||
@@ -281,7 +281,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
it("handles rule with both roles and certs combined", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp_role"])]]);
|
||||
const certFpMap = new Map<number, string>([[42, "fp_cert"]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1], allowed_cert_ids: [42] })];
|
||||
const rules = [makeRule({ allowedRoleIds: [1], allowedCertIds: [42] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, certFpMap, baseHandlers, reverseProxy);
|
||||
const match = (result![0] as any).match[0];
|
||||
@@ -292,7 +292,7 @@ describe("buildMtlsRbacSubroutes", () => {
|
||||
it("preserves base handlers order in generated routes", () => {
|
||||
const multiHandlers = [{ handler: "waf" }, { handler: "headers" }, { handler: "auth" }];
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set(["fp"])]]);
|
||||
const rules = [makeRule({ allowed_role_ids: [1] })];
|
||||
const rules = [makeRule({ allowedRoleIds: [1] })];
|
||||
|
||||
const result = buildMtlsRbacSubroutes(rules, roleFpMap, new Map(), multiHandlers, reverseProxy);
|
||||
const allowHandlers = (result![0] as any).handle;
|
||||
@@ -344,14 +344,14 @@ describe("buildFingerprintCelExpression edge cases", () => {
|
||||
|
||||
describe("resolveAllowedFingerprints edge cases", () => {
|
||||
it("handles empty arrays in rule", () => {
|
||||
const rule = makeRule({ allowed_role_ids: [], allowed_cert_ids: [] });
|
||||
const rule = makeRule({ allowedRoleIds: [], allowedCertIds: [] });
|
||||
const result = resolveAllowedFingerprints(rule, new Map(), new Map());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles role with empty fingerprint set", () => {
|
||||
const roleFpMap = new Map<number, Set<string>>([[1, new Set()]]);
|
||||
const rule = makeRule({ allowed_role_ids: [1] });
|
||||
const rule = makeRule({ allowedRoleIds: [1] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
@@ -362,7 +362,7 @@ describe("resolveAllowedFingerprints edge cases", () => {
|
||||
[2, new Set(["b", "c"])],
|
||||
[3, new Set(["c", "d"])],
|
||||
]);
|
||||
const rule = makeRule({ allowed_role_ids: [1, 2, 3] });
|
||||
const rule = makeRule({ allowedRoleIds: [1, 2, 3] });
|
||||
const result = resolveAllowedFingerprints(rule, roleFpMap, new Map());
|
||||
expect(result).toEqual(new Set(["a", "b", "c", "d"]));
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: '',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Name is required');
|
||||
@@ -78,7 +78,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'sctp' as any,
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Protocol must be 'tcp' or 'udp'");
|
||||
@@ -88,7 +88,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '',
|
||||
listenAddress: '',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Listen address is required');
|
||||
@@ -98,7 +98,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: '10.0.0.1',
|
||||
listenAddress: '10.0.0.1',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Listen address must be in format ':PORT' or 'HOST:PORT'");
|
||||
@@ -108,7 +108,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':0',
|
||||
listenAddress: ':0',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
@@ -118,7 +118,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':99999',
|
||||
listenAddress: ':99999',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Port must be between 1 and 65535');
|
||||
@@ -128,7 +128,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('At least one upstream must be specified');
|
||||
@@ -138,7 +138,7 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1'],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("must be in 'host:port' format");
|
||||
@@ -148,9 +148,9 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
listenAddress: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
tls_termination: true,
|
||||
tlsTermination: true,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('TLS termination is only supported with TCP');
|
||||
});
|
||||
@@ -159,10 +159,10 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: [],
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
@@ -171,10 +171,10 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8080',
|
||||
listenAddress: ':8080',
|
||||
upstreams: ['10.0.0.1:8080'],
|
||||
matcher_type: 'http_host',
|
||||
matcher_value: [],
|
||||
matcherType: 'http_host',
|
||||
matcherValue: [],
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher value is required');
|
||||
});
|
||||
@@ -183,9 +183,9 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
proxy_protocol_version: 'v3' as any,
|
||||
proxyProtocolVersion: 'v3' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow("Proxy protocol version must be 'v1' or 'v2'");
|
||||
});
|
||||
@@ -194,9 +194,9 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Test',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'invalid' as any,
|
||||
matcherType: 'invalid' as any,
|
||||
};
|
||||
await expect(createL4ProxyHost(input, 1)).rejects.toThrow('Matcher type must be one of');
|
||||
});
|
||||
@@ -205,33 +205,33 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Full Featured',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':993',
|
||||
listenAddress: ':993',
|
||||
upstreams: ['localhost:143'],
|
||||
matcher_type: 'tls_sni',
|
||||
matcher_value: ['mail.example.com'],
|
||||
tls_termination: true,
|
||||
proxy_protocol_version: 'v1',
|
||||
proxy_protocol_receive: true,
|
||||
matcherType: 'tls_sni',
|
||||
matcherValue: ['mail.example.com'],
|
||||
tlsTermination: true,
|
||||
proxyProtocolVersion: 'v1',
|
||||
proxyProtocolReceive: true,
|
||||
enabled: true,
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('Full Featured');
|
||||
expect(result.protocol).toBe('tcp');
|
||||
expect(result.listen_address).toBe(':993');
|
||||
expect(result.listenAddress).toBe(':993');
|
||||
expect(result.upstreams).toEqual(['localhost:143']);
|
||||
expect(result.matcher_type).toBe('tls_sni');
|
||||
expect(result.matcher_value).toEqual(['mail.example.com']);
|
||||
expect(result.tls_termination).toBe(true);
|
||||
expect(result.proxy_protocol_version).toBe('v1');
|
||||
expect(result.proxy_protocol_receive).toBe(true);
|
||||
expect(result.matcherType).toBe('tls_sni');
|
||||
expect(result.matcherValue).toEqual(['mail.example.com']);
|
||||
expect(result.tlsTermination).toBe(true);
|
||||
expect(result.proxyProtocolVersion).toBe('v1');
|
||||
expect(result.proxyProtocolReceive).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts valid UDP proxy', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'DNS',
|
||||
protocol: 'udp',
|
||||
listen_address: ':5353',
|
||||
listenAddress: ':5353',
|
||||
upstreams: ['8.8.8.8:53'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
@@ -243,54 +243,54 @@ describe('L4 proxy host create validation', () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Bound',
|
||||
protocol: 'tcp',
|
||||
listen_address: '0.0.0.0:5432',
|
||||
listenAddress: '0.0.0.0:5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.listen_address).toBe('0.0.0.0:5432');
|
||||
expect(result.listenAddress).toBe('0.0.0.0:5432');
|
||||
});
|
||||
|
||||
it('accepts none matcher without matcher_value', async () => {
|
||||
it('accepts none matcher without matcherValue', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Catch All',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
matcher_type: 'none',
|
||||
matcherType: 'none',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('none');
|
||||
expect(result.matcherType).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts proxy_protocol matcher without matcher_value', async () => {
|
||||
it('accepts proxy_protocol matcher without matcherValue', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'PP Detect',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':8443',
|
||||
listenAddress: ':8443',
|
||||
upstreams: ['10.0.0.1:443'],
|
||||
matcher_type: 'proxy_protocol',
|
||||
matcherType: 'proxy_protocol',
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.matcher_type).toBe('proxy_protocol');
|
||||
expect(result.matcherType).toBe('proxy_protocol');
|
||||
});
|
||||
|
||||
it('trims whitespace from name and listen_address', async () => {
|
||||
it('trims whitespace from name and listenAddress', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: ' Spacey Name ',
|
||||
protocol: 'tcp',
|
||||
listen_address: ' :5432 ',
|
||||
listenAddress: ' :5432 ',
|
||||
upstreams: ['10.0.0.1:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
expect(result.name).toBe('Spacey Name');
|
||||
expect(result.listen_address).toBe(':5432');
|
||||
expect(result.listenAddress).toBe(':5432');
|
||||
});
|
||||
|
||||
it('deduplicates upstreams', async () => {
|
||||
const input: L4ProxyHostInput = {
|
||||
name: 'Dedup',
|
||||
protocol: 'tcp',
|
||||
listen_address: ':5432',
|
||||
listenAddress: ':5432',
|
||||
upstreams: ['10.0.0.1:5432', '10.0.0.1:5432', '10.0.0.2:5432'],
|
||||
};
|
||||
const result = await createL4ProxyHost(input, 1);
|
||||
|
||||
Reference in New Issue
Block a user