feat(auth): complete sqlite auth and client invitations

This commit is contained in:
poyrazavsever
2026-07-16 16:21:31 +03:00
parent cce7265fb1
commit 92bc99ba12
30 changed files with 14355 additions and 170 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Public URL where users open Neta. # Public URL where users open Neta. Production'da localhost dışında HTTPS kullanın.
NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Canonical server-side app URL used by auth callbacks and trusted origin checks. # Canonical server-side app URL used by auth callbacks and trusted origin checks.
+2
View File
@@ -12,3 +12,5 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
.pnpm-store/
*.tsbuildinfo
@@ -5,8 +5,7 @@ import { format } from "date-fns";
import { tr } from "date-fns/locale"; import { tr } from "date-fns/locale";
import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms"; import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules";
import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText, UserPlus, Loader2 } from "lucide-react"; import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react";
import Link from "next/link";
import { toast } from "poyraz-ui/molecules"; import { toast } from "poyraz-ui/molecules";
import { addClientActivity } from "./actions"; import { addClientActivity } from "./actions";
@@ -66,30 +65,28 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
const [isCreatingUser, setIsCreatingUser] = useState(false); const [isCreatingUser, setIsCreatingUser] = useState(false);
const [createUserOpen, setCreateUserOpen] = useState(false); const [createUserOpen, setCreateUserOpen] = useState(false);
const [invitationUrl, setInvitationUrl] = useState<string | null>(null);
async function handleCreateUser(e: React.FormEvent<HTMLFormElement>) { async function handleCreateUser(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string; const email = formData.get("email") as string;
const password = formData.get("password") as string;
setIsCreatingUser(true); setIsCreatingUser(true);
try { try {
const res = await fetch("/api/create-client-user", { const res = await fetch("/api/create-client-user", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, client_id: client.id }) body: JSON.stringify({ email, client_id: client.id })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || data.error) { if (!res.ok || data.error) {
throw new Error(data.error || "Kullanıcı oluşturulamadı."); throw new Error(data.error || "Kullanıcı oluşturulamadı.");
} }
toast.success("Müşteri portal hesabı başarıyla oluşturuldu."); setInvitationUrl(data.invitation.invitationUrl);
setCreateUserOpen(false); toast.success("Güvenli portal daveti oluşturuldu.");
// Optional: Refresh page to reflect the new client_auth_id } catch (error: unknown) {
window.location.reload(); toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı.");
} catch (err: any) {
toast.error(err.message);
} finally { } finally {
setIsCreatingUser(false); setIsCreatingUser(false);
} }
@@ -123,9 +120,9 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
<DialogContent> <DialogContent>
<form onSubmit={handleCreateUser}> <form onSubmit={handleCreateUser}>
<DialogHeader> <DialogHeader>
<DialogTitle>Müşteri Portalı Hesabı Oluştur</DialogTitle> <DialogTitle>Müşteri Portalına Davet Et</DialogTitle>
<DialogDescription> <DialogDescription>
Müşteriniz bu e-posta ve şifre ile sisteme giriş yaparak projelerini takip edebilir. Müşterin bağlantıyı açıp kendi şifresini belirler. Davet 72 saat geçerlidir ve yalnızca bir kez kullanılabilir.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@@ -133,16 +130,33 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
<Label htmlFor="email">E-posta Adresi</Label> <Label htmlFor="email">E-posta Adresi</Label>
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} /> <Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
</div> </div>
{invitationUrl ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Geçici Şifre</Label> <Label htmlFor="invitation-url">Davet bağlantısı</Label>
<Input id="password" name="password" type="text" required minLength={6} placeholder="Min 6 karakter" /> <div className="flex gap-2">
<Input id="invitation-url" value={invitationUrl} readOnly />
<Button
type="button"
variant="outline"
size="icon"
aria-label="Davet bağlantısını kopyala"
onClick={async () => {
await navigator.clipboard.writeText(invitationUrl);
toast.success("Davet bağlantısı kopyalandı.");
}}
>
<Copy className="h-4 w-4" />
</Button>
</div> </div>
<p className="text-xs text-muted-foreground">Bağlantı yalnızca bu ekranda düz metin olarak gösterilir.</p>
</div>
) : null}
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="ghost" onClick={() => setCreateUserOpen(false)}>İptal</Button> <Button type="button" variant="ghost" onClick={() => setCreateUserOpen(false)}>İptal</Button>
<Button type="submit" disabled={isCreatingUser}> <Button type="submit" disabled={isCreatingUser || Boolean(invitationUrl)}>
{isCreatingUser && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isCreatingUser && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Hesabı Oluştur Davet Oluştur
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
+29 -95
View File
@@ -1,103 +1,37 @@
import { createInternalAuthUser } from "@/lib/auth/internal-users";
import { createClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import {
createPortalInvitation,
PortalInvitationError,
} from "@/server/auth/invitations";
import { getSessionContextFromHeaders } from "@/server/auth/session";
/**
* Legacy adapter for the current client detail screen.
* The old endpoint created a Supabase Auth user with a freelancer-chosen password.
* It now issues a one-time Better Auth invitation and never accepts a password.
*/
export async function POST(request: Request) { export async function POST(request: Request) {
const actor = await getSessionContextFromHeaders(new Headers(request.headers));
if (!actor) {
return NextResponse.json({ error: "Müşteri daveti için giriş yapmalısınız." }, { status: 401 });
}
try { try {
const { email, password, client_id } = await request.json(); const { email, client_id: clientId } = await request.json();
const invitation = await createPortalInvitation(actor, { email, clientId });
if (!email || !password || !client_id) { return NextResponse.json({ success: true, invitation }, { status: 201 });
return NextResponse.json(
{ error: "E-posta, şifre ve müşteri ID gereklidir." },
{ status: 400 },
);
}
const supabase = await createClient();
const {
data: { user },
error: userError,
} = await supabase.auth.getUser();
if (userError || !user) {
return NextResponse.json(
{ error: "Müşteri hesabı oluşturmak için giriş yapmalısınız." },
{ status: 401 },
);
}
const { data: client, error: clientError } = await supabase
.from("clients")
.select("id, client_auth_id")
.eq("id", client_id)
.eq("user_id", user.id)
.single();
if (clientError || !client) {
return NextResponse.json(
{ error: "Müşteri kaydı bulunamadı." },
{ status: 404 },
);
}
if (client.client_auth_id) {
return NextResponse.json(
{ error: "Bu müşteri için portal hesabı zaten oluşturulmuş." },
{ status: 409 },
);
}
const {
admin,
user: createdUser,
userId,
} = await createInternalAuthUser({
email,
password,
role: "client",
reason: "client_portal",
});
const { error: profileError } = await admin
.from("profiles")
.update({ role: "client" })
.eq("id", userId);
if (profileError) {
return NextResponse.json(
{
error: `Kullanıcı oluşturuldu fakat profil rolü güncellenemedi: ${profileError.message}`,
},
{ status: 500 },
);
}
const { error: updateClientError } = await admin
.from("clients")
.update({ client_auth_id: userId })
.eq("id", client_id)
.eq("user_id", user.id);
if (updateClientError) {
return NextResponse.json(
{
error: `Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi: ${updateClientError.message}`,
},
{ status: 500 },
);
}
return NextResponse.json({ success: true, user: createdUser });
} catch (error) { } catch (error) {
console.error("Create client user error:", error); if (error instanceof SyntaxError) {
return NextResponse.json( return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 });
{ }
error: if (error instanceof PortalInvitationError) {
error instanceof Error const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409;
? error.message return NextResponse.json({ error: error.message, code: error.code }, { status });
: "Sunucu tarafında beklenmeyen bir hata oluştu.", }
},
{ status: 500 }, console.error("Legacy client invitation adapter failed", error);
); return NextResponse.json({ error: "Müşteri daveti oluşturulamadı." }, { status: 500 });
} }
} }
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import {
PortalInvitationError,
setClientPortalAccess,
} from "@/server/auth/invitations";
import { getSessionContextFromHeaders } from "@/server/auth/session";
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ clientId: string }> },
) {
const actor = await getSessionContextFromHeaders(new Headers(request.headers));
if (!actor) {
return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 });
}
try {
const { enabled } = await request.json();
if (typeof enabled !== "boolean") {
return NextResponse.json({ error: "enabled boolean olmalıdır." }, { status: 400 });
}
setClientPortalAccess(actor, (await params).clientId, enabled);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 });
}
if (error instanceof PortalInvitationError) {
const status = error.code === "FORBIDDEN" ? 403 : 404;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
console.error("Client portal access update failed", error);
return NextResponse.json({ error: "Portal erişimi güncellenemedi." }, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import {
PortalInvitationError,
revokePortalInvitation,
} from "@/server/auth/invitations";
import { getSessionContextFromHeaders } from "@/server/auth/session";
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const actor = await getSessionContextFromHeaders(new Headers(request.headers));
if (!actor) {
return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 });
}
const invitationId = Number((await params).id);
if (!Number.isInteger(invitationId) || invitationId < 1) {
return NextResponse.json({ error: "Geçersiz davet kimliği." }, { status: 400 });
}
try {
revokePortalInvitation(actor, invitationId);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof PortalInvitationError) {
const status = error.code === "FORBIDDEN" ? 403 : 409;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
console.error("Portal invitation revoke failed", error);
return NextResponse.json({ error: "Davet iptal edilemedi." }, { status: 500 });
}
}
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import {
acceptPortalInvitation,
PortalInvitationError,
} from "@/server/auth/invitations";
export async function POST(request: Request) {
try {
const body = await request.json();
await acceptPortalInvitation({
token: body.token,
displayName: body.displayName,
password: body.password,
});
return NextResponse.json({ success: true }, { status: 201 });
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 });
}
if (error instanceof PortalInvitationError) {
const status = error.code === "INVALID_INPUT" ? 400 : 409;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
console.error("Portal invitation accept failed", error);
return NextResponse.json({ error: "Portal hesabı oluşturulamadı." }, { status: 500 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import {
createPortalInvitation,
PortalInvitationError,
} from "@/server/auth/invitations";
import { getSessionContextFromHeaders } from "@/server/auth/session";
export async function POST(request: Request) {
const actor = await getSessionContextFromHeaders(new Headers(request.headers));
if (!actor) {
return NextResponse.json({ error: "Oturum gerekli." }, { status: 401 });
}
try {
const body = await request.json();
const invitation = await createPortalInvitation(actor, {
clientId: body.clientId,
email: body.email,
expiresInHours: body.expiresInHours,
});
return NextResponse.json({ invitation }, { status: 201 });
} catch (error) {
if (error instanceof SyntaxError) {
return NextResponse.json({ error: "Geçersiz JSON gövdesi." }, { status: 400 });
}
return invitationErrorResponse(error);
}
}
function invitationErrorResponse(error: unknown) {
if (error instanceof PortalInvitationError) {
const status = error.code === "FORBIDDEN" ? 403 : error.code === "INVALID_INPUT" ? 400 : 409;
return NextResponse.json({ error: error.message, code: error.code }, { status });
}
console.error("Portal invitation create failed", error);
return NextResponse.json({ error: "Davet oluşturulamadı." }, { status: 500 });
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { redirect } from "next/navigation";
import {
acceptPortalInvitation,
PortalInvitationError,
} from "@/server/auth/invitations";
export async function acceptInvitation(formData: FormData) {
const token = String(formData.get("token") ?? "");
const displayName = String(formData.get("displayName") ?? "");
const password = String(formData.get("password") ?? "");
try {
await acceptPortalInvitation({ token, displayName, password });
} catch (error) {
const message =
error instanceof PortalInvitationError
? error.message
: "Portal hesabı oluşturulamadı.";
redirect(`/invite/${encodeURIComponent(token)}?error=true&message=${encodeURIComponent(message)}`);
}
redirect(
`/login?message=${encodeURIComponent("Portal hesabın oluşturuldu. Şimdi giriş yapabilirsin.")}`,
);
}
+90
View File
@@ -0,0 +1,90 @@
import { LockKeyhole, Mail, UserRound } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { acceptInvitation } from "./actions";
import { AuthPageShell } from "@/components/auth/auth-page-shell";
import { SubmitButton } from "@/components/auth/submit-button";
import { ErrorToaster } from "@/components/error-toaster";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/field";
import { getPortalInvitationPreview } from "@/server/auth/invitations";
export const dynamic = "force-dynamic";
export default async function InvitationPage({
params,
searchParams,
}: {
params: Promise<{ token: string }>;
searchParams: Promise<{ error?: string; message?: string }>;
}) {
const { token } = await params;
const invitation = getPortalInvitationPreview(token);
if (!invitation) {
notFound();
}
const query = await searchParams;
const isUsable = invitation.status === "pending";
const unavailableMessage =
invitation.status === "expired"
? "Bu davetin süresi dolmuş. Freelancer'dan yeni bir bağlantı istemelisin."
: invitation.status === "accepted"
? "Bu davet daha önce kullanılmış. Hesabınla giriş yapabilirsin."
: invitation.status === "revoked"
? "Bu davet iptal edilmiş. Freelancer'dan yeni bir bağlantı istemelisin."
: null;
return (
<>
{query.error && query.message ? <ErrorToaster message={query.message} /> : null}
<AuthPageShell
title="Müşteri portalına katıl"
description="Davet edilen hesabın için adını ve şifreni belirle."
form={
isUsable ? (
<form className="space-y-6">
<input type="hidden" name="token" value={token} />
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2">
<Mail className="h-4 w-4 text-muted-foreground" />
E-posta
</Label>
<Input id="email" type="email" value={invitation.email} disabled />
</div>
<div className="space-y-2">
<Label htmlFor="displayName" className="flex items-center gap-2">
<UserRound className="h-4 w-4 text-muted-foreground" />
Ad soyad
</Label>
<Input id="displayName" name="displayName" required maxLength={120} />
</div>
<div className="space-y-2">
<Label htmlFor="password" className="flex items-center gap-2">
<LockKeyhole className="h-4 w-4 text-muted-foreground" />
Şifre
</Label>
<Input id="password" name="password" type="password" required minLength={8} maxLength={128} />
<p className="text-xs text-muted-foreground">En az 8 karakter kullan.</p>
</div>
</div>
<SubmitButton formAction={acceptInvitation} className="h-11 w-full" pendingText="Hesap oluşturuluyor...">
Portal hesabını oluştur
</SubmitButton>
</form>
) : (
<p className="text-sm text-muted-foreground">{unavailableMessage}</p>
)
}
secondaryAction={null}
footer={
<Link href="/login" className="text-sm font-medium text-primary hover:text-primary-hover">
Giriş sayfasına dön
</Link>
}
/>
</>
);
}
+2
View File
@@ -6,6 +6,7 @@ import { auth } from '@/server/auth/auth'
import { callAuthAction } from '@/server/auth/action-handler' import { callAuthAction } from '@/server/auth/action-handler'
import { getProfileByAuthUserId } from '@/server/auth/session' import { getProfileByAuthUserId } from '@/server/auth/session'
import { import {
failFirstFreelancerSetup,
getFirstFreelancerSetupState, getFirstFreelancerSetupState,
recordAuthAuditEvent, recordAuthAuditEvent,
repairFirstFreelancerSetupForEmail, repairFirstFreelancerSetupForEmail,
@@ -85,6 +86,7 @@ export async function signup(formData: FormData) {
rememberMe: true, rememberMe: true,
}) })
} catch (error) { } catch (error) {
failFirstFreelancerSetup(credentials.email, 'better_auth_signup_failed')
const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.' const message = error instanceof Error ? error.message : 'Kullan\u0131c\u0131 olu\u015fturulamad\u0131.'
redirect(`/register?error=true&message=${encodeURIComponent(message)}`) redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
} }
+5
View File
@@ -24,6 +24,11 @@ export default async function LoginPage({
description="Neta çalışma alanına erişmek için hesabına giriş yap." description="Neta çalışma alanına erişmek için hesabına giriş yap."
form={ form={
<form className="space-y-6"> <form className="space-y-6">
{!error && message ? (
<p className="rounded-md border border-success/30 bg-success/5 p-3 text-sm text-foreground">
{String(message)}
</p>
) : null}
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="flex items-center gap-2"> <Label htmlFor="email" className="flex items-center gap-2">
+4 -1
View File
@@ -7,7 +7,10 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
DATA_DIR: /app/data DATA_DIR: /app/data
NEXT_PUBLIC_SITE_URL: http://localhost:3000 APP_URL: ${APP_URL:-http://localhost:3000}
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET must be set}
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
volumes: volumes:
- neta-data:/app/data - neta-data:/app/data
healthcheck: healthcheck:
+9 -6
View File
@@ -1,8 +1,8 @@
--- ---
title: Phase 1 Runtime, SQLite and Deploy Skeleton title: Phase 1 Runtime, SQLite and Deploy Skeleton
description: Faz 1 kapsamında eklenen Next.js standalone, SQLite, Drizzle migration, health, Docker ve backup/restore kanıtları. description: Faz 1 kapsamında eklenen Next.js standalone, SQLite, Drizzle migration, health, Docker ve backup/restore kanıtları.
status: active status: complete
last_updated: 2026-07-10 last_updated: 2026-07-16
--- ---
# Phase 1 Runtime, SQLite and Deploy Skeleton # Phase 1 Runtime, SQLite and Deploy Skeleton
@@ -33,6 +33,8 @@ Bu dosya Faz 1 için uygulanan runtime iskeletini ve local doğrulama sonuçlar
- Relative path değerleri `process.cwd()` üzerinden absolute hale getirilir. - Relative path değerleri `process.cwd()` üzerinden absolute hale getirilir.
- Uygulama `data`, `uploads`, `backups` ve `tmp` dizinlerini kontrollü oluşturur. - Uygulama `data`, `uploads`, `backups` ve `tmp` dizinlerini kontrollü oluşturur.
- Secret değerler config validation çıktısına yazılmaz; mevcut config modülü boot sırasında değer dump etmez. - Secret değerler config validation çıktısına yazılmaz; mevcut config modülü boot sırasında değer dump etmez.
- Production build sırasında route modüllerinin SQLite başlatması için her worker/process ayrı bir geçici data dizini kullanır; production runtime varsayılanı `/app/data` olarak kalır.
- `APP_URL`, `NEXT_PUBLIC_SITE_URL`, `BETTER_AUTH_SECRET` ve `TRUSTED_ORIGINS` Compose ortam sözleşmesinde açıktır.
## SQLite davranışı ## SQLite davranışı
@@ -67,7 +69,7 @@ Health response DB path, data path, schema path veya secret döndürmez.
## Doğrulama sonuçları ## Doğrulama sonuçları
2026-07-10 local sonuçları: 2026-07-16 güncel local sonuçları:
| Komut | Sonuç | Not | | Komut | Sonuç | Not |
| --- | --- | --- | | --- | --- | --- |
@@ -77,8 +79,10 @@ Health response DB path, data path, schema path veya secret döndürmez.
| `npm.cmd run phase1:smoke` | Başarılı | Temp data dir, migration, persistence, backup ve restore geçti. | | `npm.cmd run phase1:smoke` | Başarılı | Temp data dir, migration, persistence, backup ve restore geçti. |
| `npm.cmd run typecheck` | Başarılı | TypeScript temiz. | | `npm.cmd run typecheck` | Başarılı | TypeScript temiz. |
| `npm.cmd run build` | Başarılı | Next.js production build geçti, standalone output etkin. | | `npm.cmd run build` | Başarılı | Next.js production build geçti, standalone output etkin. |
| `npm.cmd run lint` | Başarısız | Faz 0 baseline ile aynı 34 error, 25 warning; yeni Faz 1 dosyaları lint çıktısında görünmedi. | | `node scripts/phase1-auth-smoke.mjs` | Başarılı | Localhost üzerinde setup, login/logout, invitation ve negatif role/token senaryoları geçti. |
| `docker compose build` | Başarısız | Docker Desktop/Linux engine çalışmıyor: daemon pipe bulunamadı. Docker smoke henüz doğrulanmadı. | | `docker compose config` | Başarılı | Production secret ve URL env sözleşmesi çözüldü. |
| `npm.cmd run lint` | Başarısız | Eski feature baseline'ında 31 error, 18 warning; değişen Faz 1 dosyalarında targeted lint temiz. |
| `docker compose build` | Çalıştırılamadı | Docker Desktop/Linux engine çalışmıyor: daemon socket bulunamadı. Docker runtime smoke henüz doğrulanmadı. |
## Backup/restore POC ## Backup/restore POC
@@ -122,4 +126,3 @@ Compose kararları:
Docker doğrulaması açık istisna: Docker doğrulaması açık istisna:
- Local Docker daemon çalışmadığı için `docker compose build`, `docker compose up`, native SQLite Linux runtime ve container restart persistence testleri yapılamadı. - Local Docker daemon çalışmadığı için `docker compose build`, `docker compose up`, native SQLite Linux runtime ve container restart persistence testleri yapılamadı.
+67 -28
View File
@@ -1,39 +1,78 @@
# Phase 2 Auth Implementation Notes ---
title: Phase 1 Auth and Client Invitation Implementation
description: Better Auth, SQLite setup lock, session guards, client invitation lifecycle and auth audit implementation notes.
status: complete
last_updated: 2026-07-16
---
## Kapsam # Phase 1 Auth and Client Invitation Implementation
Faz 2'de Supabase Auth yerine Better Auth + Drizzle SQLite temelli ilk auth katmanı eklendi. Bu faz, veri ekranlarının tamamını Supabase'ten taşımıyor; koruma noktalarını ve yeni session contract'ını hazır hale getiriyor. Bu dosya tarihsel adı korunarak Faz 1'de tamamlanan Better Auth + SQLite auth temelini kaydeder. Domain ekranlarının Supabase veri sorgularından taşınması Faz 2 ve sonraki domain fazlarının kapsamındadır; auth ve davet yaşam döngüsü artık Supabase Auth kullanmaz.
## Eklenen runtime parçaları ## Tamamlanan runtime parçaları
- `server/auth/auth.ts`: Better Auth server-only config. - `server/auth/auth.ts`: Better Auth config, first-owner user hook'ları ve session oluşturma audit/guard hook'ları.
- `app/api/auth/[...all]/route.ts`: Better Auth GET/POST Route Handler. - `server/auth/setup.ts`: transaction korumalı ilk owner kilidi, stale repair, başarısız setup kilidi temizliği ve auth audit yazımı.
- `server/db/schema/auth.ts`: Better Auth auth tabloları, Neta profile tablosu, setup lock, portal invitation ve audit log tabloları. - `server/auth/session.ts`: web ve Route Handler için session context, disabled profile ve role/client binding kontrolleri.
- `server/auth/session.ts`: request içi memoize edilen `getSessionContext`, `requireSession`, `requireFreelancer`, `requireClientUser`. - `server/auth/invitations.ts`: davet üretme, hash-only token, replacement/revoke, expiry, transaction içinde kabul ve client access enable/disable servisi.
- `server/auth/setup.ts`: ilk freelancer setup durumu, atomic setup guard ve audit yazımı. - `app/api/portal-invitations/*`: freelancer-only create/revoke ve public invitation accept adapter'ları.
- `server/auth/authorization.ts`: role ve owner assertion helper'ları. - `app/api/portal-clients/[clientId]`: client portal erişimi enable/disable adapter'ı.
- `app/invite/[token]`: davet durumu ve müşterinin kendi şifresini belirlediği kabul ekranı.
- `server/db/migrations/0002_mighty_korg.sql`: `app_profiles.client_id` identity binding ve unique index migration'ı.
## Güvenlik kararları ## İlk owner ve public registration
- `BETTER_AUTH_SECRET` production runtime'da zorunludur. Build sırasında placeholder kullanılır; runtime'da env yoksa uygulama hata verir. - Public `/api/auth/sign-up/email`, ilk owner'dan sonra database hook seviyesinde kapanır; yalnızca `/register` UI kontrolüne dayanmaz.
- `TRUSTED_ORIGINS` wildcard kabul etmez. - Eşzamanlı ilk kayıt istekleri `app_setup_state` kilidiyle serialize edilir.
- Auth cookie'leri production'da `Secure`, tüm ortamlarda `HttpOnly`, `SameSite=Lax`, `Path=/` ayarlarıyla üretilir. - İlk başarılı kullanıcı `app_profiles.role = freelancer` olarak bağlanır.
- Public sign-up endpoint'i `databaseHooks.user.create.before` ile ilk freelancer setup guard'ına bağlıdır. İlk freelancer oluştuktan sonra doğrudan `/api/auth/sign-up/email` çağrısı da kullanıcı oluşturamaz. - Better Auth user insert ile session insert arasındaki hook sırası için stale repair çalışır; profile tamamlanmadan session verilmez.
- Login hatası genel mesaj döndürür; email varlığı sızdırılmaz. - Setup başarısızsa aynı e-postaya ait pending kilit temizlenir ve `setup_failed` audit olayı yazılır.
## Bilinen sınırlar ## Client invitation sözleşmesi
- Portal client kullanıcı üretimi bu fazda sadece token modeli seviyesindedir; gerçek client invitation tüketimi sonraki veri/API fazında tamamlanacak. - Yalnızca aktif `freelancer` rolü davet oluşturabilir veya iptal edebilir.
- Eski dashboard ve portal feature sayfalarının veri sorguları hâlâ Supabase kullanıyor. Layout koruması Better Auth'a taşındı, veri okuma/yazma Faz 4-6 kapsamındadır. - Token `randomBytes(32)` ile üretilir; SQLite'ta yalnızca SHA-256 hash saklanır.
- Reverse proxy/TLS altında cookie testi Docker daemon çalışmadığı için bu turda kapatılmadı. - Varsayılan TTL 72 saat, servis üst sınırı 168 saattir.
- Aynı `clientId` için yeni davet önceki pending davetleri revoke eder ve bu değişiklik audit edilir.
- Kabul sırasında Better Auth `user`, credential `account`, `app_profiles` client kaydı, `client_id` identity bağı ve invitation `accepted` durumu tek SQLite transaction'ında yazılır.
- Kullanılmış, değiştirilmiş, süresi dolmuş veya revoke edilmiş token yeniden kullanılamaz.
- Disable işlemi profile'ı kapatır ve o client'ın aktif Better Auth session kayıtlarını aynı transaction'da siler.
- Disabled veya `client_id` bağı eksik client, Better Auth endpoint'ini doğrudan çağırsa bile session oluşturamaz.
## Doğrulama `app_profiles.client_id`, Faz 1 auth sınırında opaque domain identity bağıdır. Yerel `clients` tablosu ve foreign key Faz 2 domain migration'ında eklenecektir; mevcut Supabase client sayfaları bu nedenle henüz domain açısından hibrittir.
- `npm run db:generate` ## Audit kapsamı
- `npm run db:migrate`
- `npm run phase2:smoke`
- `npm run typecheck`
- `npm run build`
- `npm run lint`
`npm run lint` mevcut proje baseline'ındaki eski hatalar nedeniyle başarısız kalabilir; Faz 2 dosyalarında yeni lint bulgusu bırakılmamalıdır. Kapsanan olaylar:
- setup start/completion/failure ve kapalı registration denemesi;
- login success/failure ve logout;
- invitation create/revoke/expire/accept/accept failure;
- client access disable/enable.
Raw invitation token, parola, session token veya auth secret audit metadata'sına yazılmaz.
## Production güvenlik ve env
- Production runtime'da en az 32 karakter `BETTER_AUTH_SECRET` zorunludur.
- `APP_URL`, `NEXT_PUBLIC_SITE_URL`, opsiyonel `TRUSTED_ORIGINS` ve secret Compose sözleşmesine eklenmiştir.
- Wildcard trusted origin reddedilir.
- Cookie'ler HTTPS deployment'ta `Secure`; tüm ortamlarda `HttpOnly`, `SameSite=Lax`, `Path=/` kullanır. Local Docker'ın `http://localhost` kurulumu kontrollü istisnadır; production'da localhost dışındaki HTTP `APP_URL` boot sırasında reddedilir.
- Build worker'ları SQLite module initialization sırasında birbirini kilitlemesin diye production build her process için ayrı geçici data dizini kullanır. Runtime yolu değişmez: `/app/data`.
## Doğrulama — 2026-07-16
| Kontrol | Sonuç |
| --- | --- |
| `npm run typecheck` | Başarılı |
| Değişen Faz 1 dosyalarında targeted ESLint | 0 error, 0 warning |
| `node scripts/phase1-smoke.mjs` | Başarılı |
| `node scripts/phase2-auth-smoke.mjs` | Başarılı |
| `node scripts/phase1-auth-smoke.mjs` | Başarılı, ardışık iki çalışma |
| `npm run build` | Başarılı, standalone route çıktısı üretildi |
| `docker compose config` | Başarılı, secret ve URL env'leri çözüldü |
| Docker image/runtime smoke | Çalıştırılamadı; yerel Docker daemon aktif değil |
Uçtan uca auth smoke şu senaryoları kapsar: concurrent first setup, kayıt kapanışı, owner login/logout, token'ın hash saklanması, replacement revoke, davet kabulü, replay reddi, expired/revoked token reddi, client→freelancer role ihlali, disable ile session revoke, disabled direct login reddi ve enable sonrası login.
Repo geneli lint, Faz 0'da kaydedilmiş eski feature dosyalarındaki baseline hatalar nedeniyle kalite kapısı olarak açık kalır. Faz 1'de değiştirilen dosyalarda yeni lint bulgusu yoktur.
+1
View File
@@ -13,6 +13,7 @@
"db:backup": "node scripts/backup.mjs", "db:backup": "node scripts/backup.mjs",
"db:restore": "node scripts/restore.mjs", "db:restore": "node scripts/restore.mjs",
"phase1:smoke": "node scripts/phase1-smoke.mjs", "phase1:smoke": "node scripts/phase1-smoke.mjs",
"phase1:auth-smoke": "node scripts/phase1-auth-smoke.mjs",
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs", "phase2:smoke": "node scripts/phase2-auth-smoke.mjs",
"phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs" "phase3:ui-boundary": "node scripts/phase3-ui-boundary.mjs"
}, },
+12083
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true
unrs-resolver: true
+349
View File
@@ -0,0 +1,349 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import Database from "better-sqlite3";
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const port = await getAvailablePort();
const baseUrl = `http://127.0.0.1:${port}`;
const env = {
...process.env,
NODE_ENV: "development",
DATA_DIR: dataDir,
DATABASE_PATH: databasePath,
APP_URL: baseUrl,
NEXT_PUBLIC_SITE_URL: baseUrl,
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
TRUSTED_ORIGINS: baseUrl,
NEXT_TELEMETRY_DISABLED: "1",
};
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
const server = spawn(
process.execPath,
["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)],
{
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
},
);
let serverOutput = "";
server.stdout.on("data", (chunk) => {
serverOutput = `${serverOutput}${chunk}`.slice(-12000);
});
server.stderr.on("data", (chunk) => {
serverOutput = `${serverOutput}${chunk}`.slice(-12000);
});
try {
await waitForServer();
const setupAttempts = await Promise.all([
authPost("/api/auth/sign-up/email", {
name: "Owner One",
email: "owner-one@example.com",
password: "OwnerOne-Password-123",
}),
authPost("/api/auth/sign-up/email", {
name: "Owner Two",
email: "owner-two@example.com",
password: "OwnerTwo-Password-123",
}),
]);
const successfulSetups = setupAttempts.filter((attempt) => attempt.response.ok);
assert.equal(successfulSetups.length, 1, "Concurrent setup must create exactly one owner");
const setupPayload = successfulSetups[0].payload;
const ownerEmail = setupPayload.user.email;
let ownerCookie = cookieHeader(successfulSetups[0].response);
const db = new Database(databasePath);
try {
assert.equal(
db.prepare("select count(*) as value from app_profiles where role = 'freelancer'").get().value,
1,
"Exactly one freelancer profile must exist",
);
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
name: "Public Attacker",
email: "attacker@example.com",
password: "Attacker-Password-123",
});
assert.equal(rejectedRegistration.response.ok, false, "Public registration must close after setup");
if (!ownerCookie) {
const signedIn = await authPost("/api/auth/sign-in/email", {
email: ownerEmail,
password: ownerEmail.startsWith("owner-one")
? "OwnerOne-Password-123"
: "OwnerTwo-Password-123",
});
assert.equal(signedIn.response.ok, true, "Owner must be able to sign in");
ownerCookie = cookieHeader(signedIn.response);
}
assert.ok(ownerCookie, "Owner session cookie must be issued");
const anonymousInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
body: { clientId: "anonymous-client", email: "anonymous@example.com" },
});
assert.equal(anonymousInvite.response.status, 401, "Anonymous invitation creation must fail");
const invalidInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "", email: "not-an-email" },
});
assert.equal(invalidInvite.response.status, 400, "Invalid invitation input must fail");
const firstInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "client-alpha", email: "client@example.com" },
});
assert.equal(firstInvite.response.status, 201);
const rawFirstToken = tokenFromUrl(firstInvite.payload.invitation.invitationUrl);
const storedFirst = db
.prepare("select token_hash as tokenHash, status from portal_invitations where id = ?")
.get(firstInvite.payload.invitation.id);
assert.notEqual(storedFirst.tokenHash, rawFirstToken, "Raw invitation token must not be stored");
assert.equal(storedFirst.tokenHash, sha256(rawFirstToken));
const secondInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "client-alpha", email: "client@example.com" },
});
assert.equal(secondInvite.response.status, 201);
assert.equal(
db.prepare("select status from portal_invitations where id = ?").get(firstInvite.payload.invitation.id).status,
"revoked",
"A replacement invitation must revoke the prior active invitation",
);
const revokedByReplacement = await acceptInvite(rawFirstToken);
assert.equal(revokedByReplacement.response.status, 409);
const rawClientToken = tokenFromUrl(secondInvite.payload.invitation.invitationUrl);
const accepted = await acceptInvite(rawClientToken);
assert.equal(accepted.response.status, 201, JSON.stringify(accepted.payload));
const clientAuthUserId = db
.prepare("select auth_user_id as authUserId from app_profiles where email = ?")
.get("client@example.com").authUserId;
assert.deepEqual(
db
.prepare("select role, client_id as clientId, disabled from app_profiles where email = ?")
.get("client@example.com"),
{ role: "client", clientId: "client-alpha", disabled: 0 },
);
assert.notEqual(
db.prepare("select password from account where user_id = ?").get(clientAuthUserId).password,
"Client-Password-123",
"Client password must be hashed",
);
const replayed = await acceptInvite(rawClientToken);
assert.equal(replayed.response.status, 409, "Accepted invitation must be single-use");
const clientSignIn = await authPost("/api/auth/sign-in/email", {
email: "client@example.com",
password: "Client-Password-123",
});
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
const clientCookie = cookieHeader(clientSignIn.response);
const roleViolation = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: clientCookie,
body: { clientId: "forbidden-client", email: "forbidden@example.com" },
});
assert.equal(roleViolation.response.status, 403, "Client must not create invitations");
const disableClient = await jsonRequest("/api/portal-clients/client-alpha", {
method: "PATCH",
cookie: ownerCookie,
body: { enabled: false },
});
assert.equal(disableClient.response.ok, true);
const revokedSession = await fetch(`${baseUrl}/api/auth/get-session`, {
headers: { cookie: clientCookie },
});
assert.equal((await revokedSession.json()), null, "Disabling a client must revoke active sessions");
const disabledSignIn = await authPost("/api/auth/sign-in/email", {
email: "client@example.com",
password: "Client-Password-123",
});
assert.equal(disabledSignIn.response.ok, false, "Disabled client must not create a session directly");
const enableClient = await jsonRequest("/api/portal-clients/client-alpha", {
method: "PATCH",
cookie: ownerCookie,
body: { enabled: true },
});
assert.equal(enableClient.response.ok, true);
const enabledSignIn = await authPost("/api/auth/sign-in/email", {
email: "client@example.com",
password: "Client-Password-123",
});
assert.equal(enabledSignIn.response.ok, true, "Re-enabled client must be able to sign in");
const expiringInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "client-expired", email: "expired@example.com" },
});
const rawExpiredToken = tokenFromUrl(expiringInvite.payload.invitation.invitationUrl);
db.prepare("update portal_invitations set expires_at = ? where id = ?").run(
Date.now() - 1000,
expiringInvite.payload.invitation.id,
);
const expired = await acceptInvite(rawExpiredToken);
assert.equal(expired.response.status, 409);
assert.equal(expired.payload.code, "INVITATION_EXPIRED");
assert.equal(
db.prepare("select status from portal_invitations where id = ?").get(expiringInvite.payload.invitation.id).status,
"expired",
);
const manualRevokeInvite = await jsonRequest("/api/portal-invitations", {
method: "POST",
cookie: ownerCookie,
body: { clientId: "client-revoked", email: "revoked@example.com" },
});
const rawRevokedToken = tokenFromUrl(manualRevokeInvite.payload.invitation.invitationUrl);
const revoked = await jsonRequest(
`/api/portal-invitations/${manualRevokeInvite.payload.invitation.id}`,
{ method: "DELETE", cookie: ownerCookie },
);
assert.equal(revoked.response.ok, true);
assert.equal((await acceptInvite(rawRevokedToken)).response.status, 409);
const auditTypes = new Set(
db.prepare("select distinct type from auth_audit_events").all().map((row) => row.type),
);
for (const requiredType of [
"setup_started",
"setup_completed",
"registration_rejected",
"login_succeeded",
"login_failed",
"invitation_created",
"invitation_accepted",
"invitation_revoked",
"invitation_expired",
"client_access_disabled",
"client_access_enabled",
]) {
assert.ok(auditTypes.has(requiredType), `Missing audit event: ${requiredType}`);
}
const signOut = await authPost("/api/auth/sign-out", {}, ownerCookie);
assert.equal(signOut.response.ok, true);
const ownerSessionAfterLogout = await fetch(`${baseUrl}/api/auth/get-session`, {
headers: { cookie: ownerCookie },
});
assert.equal(await ownerSessionAfterLogout.json(), null, "Logout must revoke owner session");
} finally {
db.close();
}
console.log("Phase 1 auth and invitation smoke passed.");
} catch (error) {
console.error(serverOutput);
throw error;
} finally {
if (server.pid && process.platform !== "win32") {
try {
process.kill(-server.pid, "SIGTERM");
} catch {}
} else {
server.kill("SIGTERM");
}
await Promise.race([
new Promise((resolve) => server.once("exit", resolve)),
new Promise((resolve) => setTimeout(resolve, 5000)),
]);
}
async function acceptInvite(token) {
return jsonRequest("/api/portal-invitations/accept", {
method: "POST",
body: { token, displayName: "Portal Client", password: "Client-Password-123" },
});
}
async function authPost(pathname, body, cookie) {
return jsonRequest(pathname, { method: "POST", body, cookie });
}
async function jsonRequest(pathname, { method, body, cookie } = {}) {
const headers = { origin: baseUrl };
if (body !== undefined) headers["content-type"] = "application/json";
if (cookie) headers.cookie = cookie;
const response = await fetch(`${baseUrl}${pathname}`, {
method: method ?? "GET",
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const payload = text ? JSON.parse(text) : null;
return { response, payload };
}
function cookieHeader(response) {
const values = response.headers.getSetCookie?.() ?? [];
const fallback = response.headers.get("set-cookie");
return (values.length > 0 ? values : fallback ? [fallback] : [])
.map((value) => value.split(";", 1)[0])
.join("; ");
}
function tokenFromUrl(value) {
return new URL(value).pathname.split("/").at(-1);
}
function sha256(value) {
return createHash("sha256").update(value, "utf8").digest("hex");
}
async function waitForServer() {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`Next.js server exited early (${server.exitCode}).\n${serverOutput}`);
}
try {
const response = await fetch(`${baseUrl}/api/health/live`);
if (response.ok) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for Next.js server.\n${serverOutput}`);
}
function getAvailablePort() {
return new Promise((resolve, reject) => {
const listener = net.createServer();
listener.once("error", reject);
listener.listen(0, "127.0.0.1", () => {
const address = listener.address();
listener.close(() => resolve(address.port));
});
});
}
+3
View File
@@ -16,6 +16,7 @@ const requiredTables = [
const requiredIndexes = [ const requiredIndexes = [
"app_profiles_auth_user_id_unique", "app_profiles_auth_user_id_unique",
"app_profiles_client_id_unique",
"portal_invitations_token_hash_unique", "portal_invitations_token_hash_unique",
"session_user_id_idx", "session_user_id_idx",
"account_user_id_idx", "account_user_id_idx",
@@ -50,9 +51,11 @@ async function main() {
const profileColumns = sqlite.prepare("pragma table_info(app_profiles)").all(); const profileColumns = sqlite.prepare("pragma table_info(app_profiles)").all();
const roleColumn = profileColumns.find((column) => column.name === "role"); const roleColumn = profileColumns.find((column) => column.name === "role");
const disabledColumn = profileColumns.find((column) => column.name === "disabled"); const disabledColumn = profileColumns.find((column) => column.name === "disabled");
const clientIdColumn = profileColumns.find((column) => column.name === "client_id");
assert.equal(roleColumn?.notnull, 1, "app_profiles.role must be required"); assert.equal(roleColumn?.notnull, 1, "app_profiles.role must be required");
assert.equal(disabledColumn?.notnull, 1, "app_profiles.disabled must be required"); assert.equal(disabledColumn?.notnull, 1, "app_profiles.disabled must be required");
assert.ok(clientIdColumn, "app_profiles.client_id must bind invited client accounts");
console.log("Phase 2 auth smoke passed"); console.log("Phase 2 auth smoke passed");
} finally { } finally {
+4 -2
View File
@@ -7,6 +7,7 @@ import { getServerConfig } from "@/server/config";
import { getSqliteConnection } from "@/server/db/client"; import { getSqliteConnection } from "@/server/db/client";
import * as schema from "@/server/db/schema"; import * as schema from "@/server/db/schema";
import { import {
authorizeSessionCreation,
completeFirstFreelancerSetup, completeFirstFreelancerSetup,
recordAuthAuditEvent, recordAuthAuditEvent,
reserveFirstFreelancerSetup, reserveFirstFreelancerSetup,
@@ -46,12 +47,12 @@ export const auth = betterAuth({
}, },
}, },
advanced: { advanced: {
useSecureCookies: config.nodeEnv === "production", useSecureCookies: config.secureCookies,
cookiePrefix: "neta", cookiePrefix: "neta",
defaultCookieAttributes: { defaultCookieAttributes: {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
secure: config.nodeEnv === "production", secure: config.secureCookies,
path: "/", path: "/",
}, },
}, },
@@ -69,6 +70,7 @@ export const auth = betterAuth({
}, },
session: { session: {
create: { create: {
before: async (session) => authorizeSessionCreation(session.userId),
after: async (session) => { after: async (session) => {
await recordAuthAuditEvent({ await recordAuthAuditEvent({
type: "login_succeeded", type: "login_succeeded",
+487
View File
@@ -0,0 +1,487 @@
import "server-only";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { hashPassword } from "better-auth/crypto";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
import type { SessionContext } from "@/server/auth/session";
import { getServerConfig } from "@/server/config";
import { getSqliteConnection } from "@/server/db/client";
import {
account,
appProfiles,
authAuditEvents,
portalInvitations,
session,
user,
} from "@/server/db/schema";
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
const DEFAULT_INVITATION_TTL_HOURS = 72;
const createInvitationSchema = z.object({
clientId: z.string().trim().min(1).max(128),
email: z.email().transform(normalizeAuthEmail),
expiresInHours: z.number().int().min(1).max(168).default(DEFAULT_INVITATION_TTL_HOURS),
});
const acceptInvitationSchema = z.object({
token: z.string().trim().min(32).max(256),
displayName: z.string().trim().min(1).max(120),
password: z.string().min(8).max(128),
});
export type PortalInvitationErrorCode =
| "FORBIDDEN"
| "INVALID_INPUT"
| "INVITATION_NOT_FOUND"
| "INVITATION_NOT_PENDING"
| "INVITATION_EXPIRED"
| "CLIENT_ALREADY_LINKED"
| "EMAIL_ALREADY_REGISTERED";
export class PortalInvitationError extends Error {
constructor(
public readonly code: PortalInvitationErrorCode,
message: string,
) {
super(message);
this.name = "PortalInvitationError";
}
}
export type PortalInvitationPreview = {
email: string;
expiresAt: Date;
status: "pending" | "accepted" | "revoked" | "expired";
};
export async function createPortalInvitation(
actor: SessionContext,
input: z.input<typeof createInvitationSchema>,
): Promise<{ id: number; invitationUrl: string; expiresAt: Date }> {
assertFreelancerActor(actor);
const parsed = parseOrThrow(createInvitationSchema, input);
const rawToken = randomBytes(32).toString("base64url");
const tokenHash = hashInvitationToken(rawToken);
const now = new Date();
const expiresAt = new Date(now.getTime() + parsed.expiresInHours * 60 * 60 * 1000);
const { db } = getSqliteConnection();
const invitationId = db.transaction((tx) => {
const [linkedProfile] = tx
.select({ id: appProfiles.id })
.from(appProfiles)
.where(eq(appProfiles.clientId, parsed.clientId))
.limit(1)
.all();
if (linkedProfile) {
throw new PortalInvitationError(
"CLIENT_ALREADY_LINKED",
"Bu müşteri için portal hesabı zaten mevcut.",
);
}
const replacedInvitations = tx
.select({ id: portalInvitations.id, email: portalInvitations.email })
.from(portalInvitations)
.where(
and(
eq(portalInvitations.clientId, parsed.clientId),
eq(portalInvitations.status, "pending"),
),
)
.all();
tx.update(portalInvitations)
.set({ status: "revoked" })
.where(
and(
eq(portalInvitations.clientId, parsed.clientId),
eq(portalInvitations.status, "pending"),
),
)
.run();
for (const replaced of replacedInvitations) {
tx.insert(authAuditEvents)
.values({
type: "invitation_revoked",
authUserId: actor.user.id,
email: replaced.email,
metadata: { invitationId: replaced.id, reason: "replaced", clientId: parsed.clientId },
})
.run();
}
const inserted = tx
.insert(portalInvitations)
.values({
tokenHash,
clientId: parsed.clientId,
email: parsed.email,
status: "pending",
expiresAt,
createdByUserId: actor.user.id,
createdAt: now,
})
.returning({ id: portalInvitations.id })
.get();
tx.insert(authAuditEvents)
.values({
type: "invitation_created",
authUserId: actor.user.id,
email: parsed.email,
metadata: {
invitationId: inserted.id,
clientId: parsed.clientId,
expiresAt: expiresAt.toISOString(),
},
})
.run();
return inserted.id;
});
return {
id: invitationId,
invitationUrl: `${getServerConfig().appUrl}/invite/${rawToken}`,
expiresAt,
};
}
export function getPortalInvitationPreview(rawToken: string): PortalInvitationPreview | null {
if (!isPlausibleToken(rawToken)) {
return null;
}
const { db } = getSqliteConnection();
const [invitation] = db
.select({
id: portalInvitations.id,
email: portalInvitations.email,
status: portalInvitations.status,
expiresAt: portalInvitations.expiresAt,
})
.from(portalInvitations)
.where(eq(portalInvitations.tokenHash, hashInvitationToken(rawToken)))
.limit(1)
.all();
if (!invitation) {
return null;
}
if (invitation.status === "pending" && invitation.expiresAt.getTime() <= Date.now()) {
db.transaction((tx) => {
const result = tx
.update(portalInvitations)
.set({ status: "expired" })
.where(
and(
eq(portalInvitations.id, invitation.id),
eq(portalInvitations.status, "pending"),
),
)
.run();
if (result.changes > 0) {
tx.insert(authAuditEvents)
.values({
type: "invitation_expired",
email: invitation.email,
metadata: { invitationId: invitation.id },
})
.run();
}
});
return { ...invitation, status: "expired" };
}
return invitation;
}
export async function acceptPortalInvitation(input: {
token: string;
displayName?: string;
password: string;
}): Promise<{ authUserId: string; clientId: string; email: string }> {
const preview = getPortalInvitationPreview(input.token);
if (!preview) {
await recordInvitationFailure(null, "INVITATION_NOT_FOUND");
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
}
if (preview.status === "expired") {
await recordInvitationFailure(preview.email, "INVITATION_EXPIRED");
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
}
if (preview.status !== "pending") {
await recordInvitationFailure(preview.email, "INVITATION_NOT_PENDING");
throw new PortalInvitationError(
"INVITATION_NOT_PENDING",
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
);
}
const parsed = parseOrThrow(acceptInvitationSchema, {
...input,
displayName: input.displayName || getDefaultDisplayName(preview.email),
});
const passwordHash = await hashPassword(parsed.password);
const tokenHash = hashInvitationToken(parsed.token);
const authUserId = randomUUID();
const accountId = randomUUID();
const now = new Date();
const { db } = getSqliteConnection();
try {
return db.transaction((tx) => {
const [invitation] = tx
.select()
.from(portalInvitations)
.where(eq(portalInvitations.tokenHash, tokenHash))
.limit(1)
.all();
if (!invitation) {
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Davet bağlantısı geçersiz.");
}
if (invitation.status !== "pending") {
throw new PortalInvitationError(
"INVITATION_NOT_PENDING",
"Bu davet daha önce kullanılmış veya iptal edilmiş.",
);
}
if (invitation.expiresAt.getTime() <= now.getTime()) {
tx.update(portalInvitations)
.set({ status: "expired" })
.where(eq(portalInvitations.id, invitation.id))
.run();
throw new PortalInvitationError("INVITATION_EXPIRED", "Davet bağlantısının süresi dolmuş.");
}
const [existingUser] = tx
.select({ id: user.id })
.from(user)
.where(eq(user.email, invitation.email))
.limit(1)
.all();
if (existingUser) {
throw new PortalInvitationError(
"EMAIL_ALREADY_REGISTERED",
"Bu e-posta adresiyle kayıtlı bir hesap zaten var.",
);
}
const [linkedProfile] = tx
.select({ id: appProfiles.id })
.from(appProfiles)
.where(eq(appProfiles.clientId, invitation.clientId))
.limit(1)
.all();
if (linkedProfile) {
throw new PortalInvitationError(
"CLIENT_ALREADY_LINKED",
"Bu müşteri için portal hesabı zaten mevcut.",
);
}
tx.insert(user)
.values({
id: authUserId,
name: parsed.displayName,
email: invitation.email,
emailVerified: false,
createdAt: now,
updatedAt: now,
})
.run();
tx.insert(account)
.values({
id: accountId,
accountId: authUserId,
providerId: "credential",
userId: authUserId,
password: passwordHash,
createdAt: now,
updatedAt: now,
})
.run();
tx.insert(appProfiles)
.values({
authUserId,
email: invitation.email,
displayName: parsed.displayName,
role: "client",
clientId: invitation.clientId,
disabled: false,
createdAt: now,
updatedAt: now,
})
.run();
const accepted = tx
.update(portalInvitations)
.set({ status: "accepted", acceptedAt: now })
.where(
and(
eq(portalInvitations.id, invitation.id),
eq(portalInvitations.status, "pending"),
),
)
.run();
if (accepted.changes !== 1) {
throw new PortalInvitationError(
"INVITATION_NOT_PENDING",
"Davet başka bir istek tarafından kullanıldı.",
);
}
tx.insert(authAuditEvents)
.values({
type: "invitation_accepted",
authUserId,
email: invitation.email,
metadata: { invitationId: invitation.id, clientId: invitation.clientId },
})
.run();
return { authUserId, clientId: invitation.clientId, email: invitation.email };
});
} catch (error) {
const code = error instanceof PortalInvitationError ? error.code : "transaction_failed";
await recordInvitationFailure(preview.email, code);
throw error;
}
}
export function revokePortalInvitation(actor: SessionContext, invitationId: number): void {
assertFreelancerActor(actor);
const { db } = getSqliteConnection();
db.transaction((tx) => {
const [invitation] = tx
.select({ id: portalInvitations.id, email: portalInvitations.email })
.from(portalInvitations)
.where(
and(
eq(portalInvitations.id, invitationId),
eq(portalInvitations.status, "pending"),
),
)
.limit(1)
.all();
if (!invitation) {
throw new PortalInvitationError(
"INVITATION_NOT_PENDING",
"Aktif davet bulunamadı.",
);
}
tx.update(portalInvitations)
.set({ status: "revoked" })
.where(eq(portalInvitations.id, invitation.id))
.run();
tx.insert(authAuditEvents)
.values({
type: "invitation_revoked",
authUserId: actor.user.id,
email: invitation.email,
metadata: { invitationId },
})
.run();
});
}
export function setClientPortalAccess(
actor: SessionContext,
clientId: string,
enabled: boolean,
): void {
assertFreelancerActor(actor);
const { db } = getSqliteConnection();
db.transaction((tx) => {
const [profile] = tx
.select({ authUserId: appProfiles.authUserId, email: appProfiles.email })
.from(appProfiles)
.where(and(eq(appProfiles.clientId, clientId), eq(appProfiles.role, "client")))
.limit(1)
.all();
if (!profile) {
throw new PortalInvitationError("INVITATION_NOT_FOUND", "Müşteri portal hesabı bulunamadı.");
}
tx.update(appProfiles)
.set({ disabled: !enabled, updatedAt: new Date() })
.where(eq(appProfiles.authUserId, profile.authUserId))
.run();
if (!enabled) {
tx.delete(session).where(eq(session.userId, profile.authUserId)).run();
}
tx.insert(authAuditEvents)
.values({
type: enabled ? "client_access_enabled" : "client_access_disabled",
authUserId: actor.user.id,
email: profile.email,
metadata: { clientId, targetAuthUserId: profile.authUserId },
})
.run();
});
}
export function hashInvitationToken(rawToken: string): string {
return createHash("sha256").update(rawToken, "utf8").digest("hex");
}
function assertFreelancerActor(actor: SessionContext): void {
if (actor.profile.disabled || actor.profile.role !== "freelancer") {
throw new PortalInvitationError("FORBIDDEN", "Bu işlem için yetkiniz yok.");
}
}
function isPlausibleToken(value: string): boolean {
return typeof value === "string" && value.length >= 32 && value.length <= 256;
}
function parseOrThrow<TSchema extends z.ZodType>(
schema: TSchema,
input: unknown,
): z.output<TSchema> {
const result = schema.safeParse(input);
if (!result.success) {
throw new PortalInvitationError("INVALID_INPUT", "Girilen bilgiler geçersiz.");
}
return result.data;
}
async function recordInvitationFailure(email: string | null, reason: string): Promise<void> {
const { db } = getSqliteConnection();
db.insert(authAuditEvents)
.values({
type: "invitation_accept_failed",
email,
metadata: { reason },
})
.run();
}
+17 -4
View File
@@ -20,13 +20,20 @@ export type SessionContext = {
email: string; email: string;
displayName: string; displayName: string;
role: UserRole; role: UserRole;
clientId: string | null;
disabled: boolean; disabled: boolean;
}; };
}; };
export const getSessionContext = cache(async (): Promise<SessionContext | null> => { export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
return getSessionContextFromHeaders(await headers());
});
export async function getSessionContextFromHeaders(
requestHeaders: Headers,
): Promise<SessionContext | null> {
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers(), headers: requestHeaders,
query: { query: {
disableCookieCache: true, disableCookieCache: true,
}, },
@@ -38,7 +45,12 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
const profile = getProfileByAuthUserId(session.user.id); const profile = getProfileByAuthUserId(session.user.id);
if (!profile || profile.disabled || profile.authUserId !== session.user.id) { if (
!profile ||
profile.disabled ||
profile.authUserId !== session.user.id ||
(profile.role === "client" && !profile.clientId)
) {
return null; return null;
} }
@@ -47,7 +59,7 @@ export const getSessionContext = cache(async (): Promise<SessionContext | null>
user: session.user, user: session.user,
profile, profile,
}; };
}); }
export async function requireSession(): Promise<SessionContext> { export async function requireSession(): Promise<SessionContext> {
const context = await getSessionContext(); const context = await getSessionContext();
@@ -72,7 +84,7 @@ export async function requireFreelancer(): Promise<SessionContext> {
export async function requireClientUser(): Promise<SessionContext> { export async function requireClientUser(): Promise<SessionContext> {
const context = await requireSession(); const context = await requireSession();
if (context.profile.role !== "client") { if (context.profile.role !== "client" || !context.profile.clientId) {
redirect("/"); redirect("/");
} }
@@ -88,6 +100,7 @@ export function getProfileByAuthUserId(authUserId: string): SessionContext["prof
email: appProfiles.email, email: appProfiles.email,
displayName: appProfiles.displayName, displayName: appProfiles.displayName,
role: appProfiles.role, role: appProfiles.role,
clientId: appProfiles.clientId,
disabled: appProfiles.disabled, disabled: appProfiles.disabled,
}) })
.from(appProfiles) .from(appProfiles)
+129 -4
View File
@@ -122,6 +122,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
.all(); .all();
if (freelancerCount > 0) { if (freelancerCount > 0) {
tx.insert(authAuditEvents)
.values({
type: "registration_rejected",
email: normalizedEmail,
metadata: { reason: "setup_completed" },
})
.run();
return false; return false;
} }
@@ -135,6 +142,13 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
const now = new Date(); const now = new Date();
if (setupState?.status === "completed") { if (setupState?.status === "completed") {
tx.insert(authAuditEvents)
.values({
type: "registration_rejected",
email: normalizedEmail,
metadata: { reason: "setup_completed" },
})
.run();
return false; return false;
} }
@@ -178,21 +192,52 @@ export async function reserveFirstFreelancerSetup(email: string): Promise<boolea
}); });
} }
export function failFirstFreelancerSetup(email: string, reason: string): void {
const normalizedEmail = normalizeAuthEmail(email);
const { db } = getSqliteConnection();
db.transaction((tx) => {
const [setupState] = tx
.select()
.from(appSetupState)
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
.limit(1)
.all();
if (setupState?.status === "pending" && setupState.lockedBy === normalizedEmail) {
tx.delete(appSetupState)
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
.run();
}
tx.insert(authAuditEvents)
.values({
type: "setup_failed",
email: normalizedEmail,
metadata: { reason },
})
.run();
});
}
export async function completeFirstFreelancerSetup(user: { export async function completeFirstFreelancerSetup(user: {
id: string; id: string;
email: string; email: string;
name?: string | null; name?: string | null;
}): Promise<void> { }): Promise<void> {
const normalizedEmail = normalizeAuthEmail(user.email); const normalizedEmail = normalizeAuthEmail(user.email);
const now = new Date();
const { db } = getSqliteConnection(); const { db } = getSqliteConnection();
db.transaction((tx) => { db.transaction((tx) => {
completeFirstFreelancerSetupInTransaction(tx, { completeFirstFreelancerSetupInTransaction(
tx,
{
id: user.id, id: user.id,
email: normalizedEmail, email: normalizedEmail,
name: user.name ?? null, name: user.name ?? null,
}); },
false,
);
}); });
} }
@@ -205,9 +250,29 @@ function completeFirstFreelancerSetupInTransaction(
email: string; email: string;
name?: string | null; name?: string | null;
}, },
repaired = true,
): void { ): void {
const normalizedEmail = normalizeAuthEmail(user.email); const normalizedEmail = normalizeAuthEmail(user.email);
const now = new Date(); const now = new Date();
const [existingProfile] = tx
.select({ id: appProfiles.id })
.from(appProfiles)
.where(eq(appProfiles.authUserId, user.id))
.limit(1)
.all();
if (existingProfile) {
tx.update(appSetupState)
.set({
status: "completed",
lockedBy: normalizedEmail,
completedAt: now,
updatedAt: now,
})
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
.run();
return;
}
tx.insert(appProfiles) tx.insert(appProfiles)
.values({ .values({
@@ -215,6 +280,7 @@ function completeFirstFreelancerSetupInTransaction(
email: normalizedEmail, email: normalizedEmail,
displayName: user.name || getDefaultDisplayName(normalizedEmail), displayName: user.name || getDefaultDisplayName(normalizedEmail),
role: "freelancer", role: "freelancer",
clientId: null,
disabled: false, disabled: false,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
@@ -247,7 +313,7 @@ function completeFirstFreelancerSetupInTransaction(
type: "setup_completed", type: "setup_completed",
authUserId: user.id, authUserId: user.id,
email: normalizedEmail, email: normalizedEmail,
metadata: { role: "freelancer", repaired: true }, metadata: { role: "freelancer", repaired },
}) })
.run(); .run();
} }
@@ -269,3 +335,62 @@ export async function recordAuthAuditEvent(input: {
}) })
.run(); .run();
} }
export function authorizeSessionCreation(authUserId: string): boolean {
const { db } = getSqliteConnection();
let [profile] = db
.select({
email: appProfiles.email,
role: appProfiles.role,
clientId: appProfiles.clientId,
disabled: appProfiles.disabled,
})
.from(appProfiles)
.where(eq(appProfiles.authUserId, authUserId))
.limit(1)
.all();
if (!profile) {
const [authUser] = db
.select({ email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, authUserId))
.limit(1)
.all();
if (authUser && repairFirstFreelancerSetupForEmail(authUser.email)) {
[profile] = db
.select({
email: appProfiles.email,
role: appProfiles.role,
clientId: appProfiles.clientId,
disabled: appProfiles.disabled,
})
.from(appProfiles)
.where(eq(appProfiles.authUserId, authUserId))
.limit(1)
.all();
}
}
if (profile && !profile.disabled && (profile.role !== "client" || profile.clientId)) {
return true;
}
db.insert(authAuditEvents)
.values({
type: "login_failed",
authUserId: profile ? authUserId : null,
email: profile?.email ?? null,
metadata: {
reason: profile?.disabled
? "disabled_profile"
: profile?.role === "client" && !profile.clientId
? "unlinked_client_profile"
: "missing_profile",
},
})
.run();
return false;
}
+14 -2
View File
@@ -4,10 +4,22 @@ export type UserRole = (typeof userRoles)[number];
export type SetupStatus = "pending" | "completed"; export type SetupStatus = "pending" | "completed";
export const portalInvitationStatuses = ["pending", "accepted", "revoked", "expired"] as const;
export type PortalInvitationStatus = (typeof portalInvitationStatuses)[number];
export type AuthAuditEventType = export type AuthAuditEventType =
| "setup_started" | "setup_started"
| "setup_completed" | "setup_completed"
| "setup_failed"
| "registration_rejected"
| "login_succeeded" | "login_succeeded"
| "login_failed" | "login_failed"
| "logout_succeeded"; | "logout_succeeded"
| "invitation_created"
| "invitation_revoked"
| "invitation_expired"
| "invitation_accepted"
| "invitation_accept_failed"
| "client_access_disabled"
| "client_access_enabled";
+26 -3
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { z } from "zod"; import { z } from "zod";
@@ -24,6 +25,7 @@ export type ServerConfig = {
tmpDir: string; tmpDir: string;
appUrl: string; appUrl: string;
trustedOrigins: string[]; trustedOrigins: string[];
secureCookies: boolean;
betterAuthSecret?: string; betterAuthSecret?: string;
}; };
@@ -35,12 +37,16 @@ export function getServerConfig(): ServerConfig {
} }
const parsed = envSchema.parse(process.env); const parsed = envSchema.parse(process.env);
const isProductionBuild = process.env.NEXT_PHASE === "phase-production-build";
const defaultDataDir = isProductionBuild
? path.join(os.tmpdir(), `neta-production-build-${process.pid}`)
: parsed.NODE_ENV === "production"
? "/app/data"
: path.join(process.cwd(), ".data");
const dataDir = path.resolve( const dataDir = path.resolve(
parsed.DATA_DIR && parsed.DATA_DIR.length > 0 parsed.DATA_DIR && parsed.DATA_DIR.length > 0
? parsed.DATA_DIR ? parsed.DATA_DIR
: parsed.NODE_ENV === "production" : defaultDataDir,
? "/app/data"
: path.join(process.cwd(), ".data"),
); );
const databasePath = path.resolve( const databasePath = path.resolve(
@@ -55,6 +61,7 @@ export function getServerConfig(): ServerConfig {
parsed.NEXT_PUBLIC_SITE_URL || parsed.NEXT_PUBLIC_SITE_URL ||
"http://localhost:3000", "http://localhost:3000",
); );
const secureCookies = validateAppUrlSecurity(appUrl, parsed.NODE_ENV);
const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl); const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl);
const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV); const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV);
@@ -67,6 +74,7 @@ export function getServerConfig(): ServerConfig {
tmpDir: path.join(dataDir, "tmp"), tmpDir: path.join(dataDir, "tmp"),
appUrl, appUrl,
trustedOrigins, trustedOrigins,
secureCookies,
betterAuthSecret, betterAuthSecret,
}; };
@@ -98,6 +106,21 @@ function normalizeTrustedOrigins(value: string | undefined, appUrl: string): str
return [...origins]; return [...origins];
} }
function validateAppUrlSecurity(
appUrl: string,
nodeEnv: ServerConfig["nodeEnv"],
): boolean {
const url = new URL(appUrl);
const isHttps = url.protocol === "https:";
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
if (nodeEnv === "production" && !isHttps && !isLoopback) {
throw new Error("Production APP_URL HTTPS kullanmalidir; HTTP yalnizca localhost icin desteklenir.");
}
return isHttps;
}
function normalizeAuthSecret( function normalizeAuthSecret(
value: string | undefined, value: string | undefined,
nodeEnv: ServerConfig["nodeEnv"], nodeEnv: ServerConfig["nodeEnv"],
@@ -0,0 +1,2 @@
ALTER TABLE `app_profiles` ADD `client_id` text;--> statement-breakpoint
CREATE UNIQUE INDEX `app_profiles_client_id_unique` ON `app_profiles` (`client_id`);
@@ -0,0 +1,804 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9dcf0ef9-1e97-48d0-87ac-a5da54e16bc5",
"prevId": "cb8d4285-951f-4bbf-b848-953bdf769836",
"tables": {
"account": {
"name": "account",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"account_user_id_idx": {
"name": "account_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"app_profiles": {
"name": "app_profiles",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"auth_user_id": {
"name": "auth_user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"disabled": {
"name": "disabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"app_profiles_auth_user_id_unique": {
"name": "app_profiles_auth_user_id_unique",
"columns": [
"auth_user_id"
],
"isUnique": true
},
"app_profiles_client_id_unique": {
"name": "app_profiles_client_id_unique",
"columns": [
"client_id"
],
"isUnique": true
},
"app_profiles_role_idx": {
"name": "app_profiles_role_idx",
"columns": [
"role"
],
"isUnique": false
}
},
"foreignKeys": {
"app_profiles_auth_user_id_user_id_fk": {
"name": "app_profiles_auth_user_id_user_id_fk",
"tableFrom": "app_profiles",
"tableTo": "user",
"columnsFrom": [
"auth_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"app_setup_state": {
"name": "app_setup_state",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"locked_by": {
"name": "locked_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"locked_at": {
"name": "locked_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completed_at": {
"name": "completed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"auth_audit_events": {
"name": "auth_audit_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"auth_user_id": {
"name": "auth_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"auth_audit_events_type_idx": {
"name": "auth_audit_events_type_idx",
"columns": [
"type"
],
"isUnique": false
},
"auth_audit_events_auth_user_id_idx": {
"name": "auth_audit_events_auth_user_id_idx",
"columns": [
"auth_user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"auth_audit_events_auth_user_id_user_id_fk": {
"name": "auth_audit_events_auth_user_id_user_id_fk",
"tableFrom": "auth_audit_events",
"tableTo": "user",
"columnsFrom": [
"auth_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"portal_invitations": {
"name": "portal_invitations",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"accepted_at": {
"name": "accepted_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_by_user_id": {
"name": "created_by_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"portal_invitations_token_hash_unique": {
"name": "portal_invitations_token_hash_unique",
"columns": [
"token_hash"
],
"isUnique": true
},
"portal_invitations_client_id_idx": {
"name": "portal_invitations_client_id_idx",
"columns": [
"client_id"
],
"isUnique": false
},
"portal_invitations_email_idx": {
"name": "portal_invitations_email_idx",
"columns": [
"email"
],
"isUnique": false
}
},
"foreignKeys": {
"portal_invitations_created_by_user_id_user_id_fk": {
"name": "portal_invitations_created_by_user_id_user_id_fk",
"tableFrom": "portal_invitations",
"tableTo": "user",
"columnsFrom": [
"created_by_user_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"session": {
"name": "session",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"session_token_unique": {
"name": "session_token_unique",
"columns": [
"token"
],
"isUnique": true
},
"session_user_id_idx": {
"name": "session_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
}
},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_verified": {
"name": "email_verified",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"verification": {
"name": "verification",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
"verification_identifier_idx": {
"name": "verification_identifier_idx",
"columns": [
"identifier"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"runtime_checks": {
"name": "runtime_checks",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"runtime_events": {
"name": "runtime_events",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1783709956320, "when": 1783709956320,
"tag": "0001_silky_jetstream", "tag": "0001_silky_jetstream",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1784205329112,
"tag": "0002_mighty_korg",
"breakpoints": true
} }
] ]
} }
+9 -3
View File
@@ -1,6 +1,11 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
import type { AuthAuditEventType, SetupStatus, UserRole } from "@/server/auth/types"; import type {
AuthAuditEventType,
PortalInvitationStatus,
SetupStatus,
UserRole,
} from "@/server/auth/types";
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`; const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
@@ -86,6 +91,7 @@ export const appProfiles = sqliteTable(
email: text("email").notNull(), email: text("email").notNull(),
displayName: text("display_name").notNull(), displayName: text("display_name").notNull(),
role: text("role").$type<UserRole>().notNull(), role: text("role").$type<UserRole>().notNull(),
clientId: text("client_id"),
disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(), disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(), createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }) updatedAt: integer("updated_at", { mode: "timestamp_ms" })
@@ -95,6 +101,7 @@ export const appProfiles = sqliteTable(
}, },
(table) => [ (table) => [
uniqueIndex("app_profiles_auth_user_id_unique").on(table.authUserId), uniqueIndex("app_profiles_auth_user_id_unique").on(table.authUserId),
uniqueIndex("app_profiles_client_id_unique").on(table.clientId),
index("app_profiles_role_idx").on(table.role), index("app_profiles_role_idx").on(table.role),
], ],
); );
@@ -118,7 +125,7 @@ export const portalInvitations = sqliteTable(
tokenHash: text("token_hash").notNull(), tokenHash: text("token_hash").notNull(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
email: text("email").notNull(), email: text("email").notNull(),
status: text("status", { enum: ["pending", "accepted", "revoked", "expired"] }) status: text("status").$type<PortalInvitationStatus>()
.default("pending") .default("pending")
.notNull(), .notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(), expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
@@ -148,4 +155,3 @@ export const authAuditEvents = sqliteTable(
index("auth_audit_events_auth_user_id_idx").on(table.authUserId), index("auth_audit_events_auth_user_id_idx").on(table.authUserId),
], ],
); );