feat: add better auth sqlite runtime and server-side session flow
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.data
|
||||||
|
backups
|
||||||
|
.git
|
||||||
|
.env*
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
+20
-6
@@ -1,14 +1,28 @@
|
|||||||
# Public URL where users open Neta.
|
# Public URL where users open Neta.
|
||||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Supabase project API URL, for example:
|
# Canonical server-side app URL used by auth callbacks and trusted origin checks.
|
||||||
# https://your-project-ref.supabase.co
|
# Defaults to NEXT_PUBLIC_SITE_URL when empty.
|
||||||
|
APP_URL=
|
||||||
|
|
||||||
|
# Optional Better Auth base URL override. Defaults to APP_URL/NEXT_PUBLIC_SITE_URL.
|
||||||
|
BETTER_AUTH_URL=
|
||||||
|
|
||||||
|
# Required at production runtime. Generate with: openssl rand -base64 32
|
||||||
|
BETTER_AUTH_SECRET=
|
||||||
|
|
||||||
|
# Optional comma-separated extra trusted origins. Wildcards are rejected.
|
||||||
|
TRUSTED_ORIGINS=
|
||||||
|
|
||||||
|
# Persistent application data directory. In Docker this should be /app/data.
|
||||||
|
DATA_DIR=.data
|
||||||
|
|
||||||
|
# Optional explicit SQLite database path. Defaults to DATA_DIR/neta.db.
|
||||||
|
DATABASE_PATH=
|
||||||
|
|
||||||
|
# Legacy Supabase values are kept only while old feature data screens are being migrated.
|
||||||
NEXT_PUBLIC_SUPABASE_URL=
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
|
||||||
# Supabase anon/public key.
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
# Supabase service role key. Required for creating the first admin,
|
|
||||||
# creating client portal users, and server-side storage uploads.
|
|
||||||
# Keep this secret. Never expose it with a NEXT_PUBLIC_ prefix.
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ out
|
|||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
backups/
|
backups/
|
||||||
|
.data/
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.full.example
|
!.env.full.example
|
||||||
|
|||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:22-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:22-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
RUN groupadd --system --gid 1001 nodejs \
|
||||||
|
&& useradd --system --uid 1001 --gid nodejs nextjs \
|
||||||
|
&& mkdir -p /app/data \
|
||||||
|
&& chown -R nextjs:nodejs /app/data
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/scripts ./scripts
|
||||||
|
COPY --from=builder /app/server/db/migrations ./server/db/migrations
|
||||||
|
COPY --from=builder /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||||
|
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||||
|
COPY --from=builder /app/node_modules/bindings ./node_modules/bindings
|
||||||
|
COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
VOLUME ["/app/data"]
|
||||||
|
CMD ["sh", "-c", "node scripts/migrate.mjs && node server.js"]
|
||||||
+13
-36
@@ -1,53 +1,30 @@
|
|||||||
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
import { DashboardShell } from "@/components/layout/dashboard-shell";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { requireFreelancer } from "@/server/auth/session";
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const supabase = await createClient();
|
const { user, profile } = await requireFreelancer();
|
||||||
const {
|
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Neta Kullanıcısı";
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const { data: profile } = user
|
const shortName =
|
||||||
? await supabase
|
displayName
|
||||||
.from("profiles")
|
.split(" ")
|
||||||
.select("first_name, last_name, avatar_url, role")
|
.filter(Boolean)
|
||||||
.eq("id", user.id)
|
.slice(0, 2)
|
||||||
.maybeSingle()
|
.map((part) => part[0]?.toUpperCase())
|
||||||
: { data: null };
|
.join("")
|
||||||
|
.slice(0, 2) || "MS";
|
||||||
if (profile?.role === "client") {
|
|
||||||
const { redirect } = await import("next/navigation");
|
|
||||||
redirect("/portal");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackName = user?.email?.split("@")[0] ?? "Neta Kullanıcısı";
|
|
||||||
const displayName =
|
|
||||||
[profile?.first_name, profile?.last_name].filter(Boolean).join(" ") ||
|
|
||||||
fallbackName;
|
|
||||||
|
|
||||||
const shortName = displayName
|
|
||||||
.split(" ")
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0]?.toUpperCase())
|
|
||||||
.join("")
|
|
||||||
.slice(0, 2) || "MS";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell
|
<DashboardShell
|
||||||
user={{
|
user={{
|
||||||
email: user?.email ?? "bilinmiyor@mindspace.local",
|
email: user.email,
|
||||||
displayName,
|
displayName,
|
||||||
shortName,
|
shortName,
|
||||||
avatarUrl:
|
avatarUrl: user.image || null,
|
||||||
profile?.avatar_url ||
|
|
||||||
user?.user_metadata?.avatar_url ||
|
|
||||||
user?.user_metadata?.picture ||
|
|
||||||
null,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
import { auth } from "@/server/auth/auth";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return Response.json({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { checkReadiness } from "@/server/db/health";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
const readiness = checkReadiness();
|
||||||
|
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
status: readiness.ok ? "ok" : "unhealthy",
|
||||||
|
checks: readiness.checks,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{ status: readiness.ok ? 200 : 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
+49
-35
@@ -1,31 +1,56 @@
|
|||||||
'use server'
|
'use server'
|
||||||
|
|
||||||
import { revalidatePath } from 'next/cache'
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { auth } from '@/server/auth/auth'
|
||||||
import { getFirstAdminSetupState } from '@/lib/auth/first-admin-setup'
|
import { getProfileByAuthUserId } from '@/server/auth/session'
|
||||||
import { createInternalAuthUser } from '@/lib/auth/internal-users'
|
import { getFirstFreelancerSetupState, recordAuthAuditEvent } from '@/server/auth/setup'
|
||||||
|
import { getDefaultDisplayName, parseAuthCredentials } from '@/server/auth/validation'
|
||||||
|
|
||||||
|
const genericLoginError = 'E-posta veya şifre hatalı.'
|
||||||
|
|
||||||
export async function login(formData: FormData) {
|
export async function login(formData: FormData) {
|
||||||
const supabase = await createClient()
|
const credentials = parseAuthCredentials(formData)
|
||||||
|
let redirectTarget = '/'
|
||||||
|
|
||||||
const data = {
|
try {
|
||||||
email: formData.get('email') as string,
|
const result = await auth.api.signInEmail({
|
||||||
password: formData.get('password') as string,
|
body: {
|
||||||
}
|
email: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
|
rememberMe: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const profile = getProfileByAuthUserId(result.user.id)
|
||||||
|
|
||||||
const { error } = await supabase.auth.signInWithPassword(data)
|
if (!profile || profile.disabled) {
|
||||||
|
await auth.api.signOut({ headers: await headers() })
|
||||||
|
await recordAuthAuditEvent({
|
||||||
|
type: 'login_failed',
|
||||||
|
authUserId: result.user.id,
|
||||||
|
email: credentials.email,
|
||||||
|
metadata: { reason: 'missing_or_disabled_profile' },
|
||||||
|
})
|
||||||
|
redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`)
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
redirectTarget = profile.role === 'client' ? '/portal' : '/'
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`)
|
} catch {
|
||||||
|
await recordAuthAuditEvent({
|
||||||
|
type: 'login_failed',
|
||||||
|
email: credentials.email,
|
||||||
|
metadata: { reason: 'invalid_credentials' },
|
||||||
|
})
|
||||||
|
redirect(`/login?error=true&message=${encodeURIComponent(genericLoginError)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
redirect('/')
|
redirect(redirectTarget)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signup(formData: FormData) {
|
export async function signup(formData: FormData) {
|
||||||
const setupState = await getFirstAdminSetupState()
|
const setupState = await getFirstFreelancerSetupState()
|
||||||
|
|
||||||
if (setupState.errorMessage) {
|
if (setupState.errorMessage) {
|
||||||
redirect(`/register?error=true&message=${encodeURIComponent(setupState.errorMessage)}`)
|
redirect(`/register?error=true&message=${encodeURIComponent(setupState.errorMessage)}`)
|
||||||
@@ -34,44 +59,33 @@ export async function signup(formData: FormData) {
|
|||||||
if (!setupState.available) {
|
if (!setupState.available) {
|
||||||
redirect(
|
redirect(
|
||||||
`/login?error=true&message=${encodeURIComponent(
|
`/login?error=true&message=${encodeURIComponent(
|
||||||
'Kayıt kapalı. Bu Neta kurulumunda ilk admin hesabı zaten oluşturulmuş.',
|
'Kayıt kapalı. Bu Neta kurulumunda ilk freelancer hesabı zaten oluşturulmuş.',
|
||||||
)}`,
|
)}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const credentials = parseAuthCredentials(formData)
|
||||||
email: formData.get('email') as string,
|
|
||||||
password: formData.get('password') as string,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createInternalAuthUser({
|
await auth.api.signUpEmail({
|
||||||
email: data.email,
|
body: {
|
||||||
password: data.password,
|
name: getDefaultDisplayName(credentials.email),
|
||||||
role: 'freelancer',
|
email: credentials.email,
|
||||||
reason: 'first_admin',
|
password: credentials.password,
|
||||||
|
rememberMe: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message = error instanceof Error ? error.message : 'Kullanıcı oluşturulamadı.'
|
||||||
error instanceof Error ? error.message : 'Kullanıcı oluşturulamadı.'
|
|
||||||
redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
|
redirect(`/register?error=true&message=${encodeURIComponent(message)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = await createClient()
|
|
||||||
const { error } = await supabase.auth.signInWithPassword(data)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(error.message)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
redirect('/')
|
redirect('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signOut() {
|
export async function signOut() {
|
||||||
const supabase = await createClient()
|
await auth.api.signOut({ headers: await headers() })
|
||||||
|
|
||||||
await supabase.auth.signOut()
|
|
||||||
|
|
||||||
revalidatePath('/', 'layout')
|
revalidatePath('/', 'layout')
|
||||||
redirect('/login')
|
redirect('/login')
|
||||||
|
|||||||
+14
-53
@@ -1,71 +1,32 @@
|
|||||||
import { PortalShell } from "@/components/layout/portal-shell";
|
import { PortalShell } from "@/components/layout/portal-shell";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { requireClientUser } from "@/server/auth/session";
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export default async function PortalLayout({
|
export default async function PortalLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const supabase = await createClient();
|
const { user, profile } = await requireClientUser();
|
||||||
const {
|
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Müşteri";
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
const shortName =
|
||||||
redirect("/login");
|
displayName
|
||||||
}
|
.split(" ")
|
||||||
|
.filter(Boolean)
|
||||||
const { data: profile } = await supabase
|
.slice(0, 2)
|
||||||
.from("profiles")
|
.map((part) => part[0]?.toUpperCase())
|
||||||
.select("first_name, last_name, avatar_url, role")
|
.join("")
|
||||||
.eq("id", user.id)
|
.slice(0, 2) || "MS";
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (profile?.role !== "client") {
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackName = user.email?.split("@")[0] ?? "Müşteri";
|
|
||||||
const displayName =
|
|
||||||
[profile?.first_name, profile?.last_name].filter(Boolean).join(" ") ||
|
|
||||||
fallbackName;
|
|
||||||
|
|
||||||
const shortName = displayName
|
|
||||||
.split(" ")
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((part) => part[0]?.toUpperCase())
|
|
||||||
.join("")
|
|
||||||
.slice(0, 2) || "MS";
|
|
||||||
|
|
||||||
const { data: clientData } = await supabase
|
|
||||||
.from("clients")
|
|
||||||
.select("id")
|
|
||||||
.eq("client_auth_id", user.id)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
let avgProgress = 0;
|
|
||||||
if (clientData) {
|
|
||||||
const { data: projectsData } = await supabase
|
|
||||||
.from("projects")
|
|
||||||
.select("progress")
|
|
||||||
.eq("client_id", clientData.id)
|
|
||||||
.eq("status", "active");
|
|
||||||
if (projectsData && projectsData.length > 0) {
|
|
||||||
avgProgress = Math.round(projectsData.reduce((sum, p) => sum + p.progress, 0) / projectsData.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PortalShell
|
<PortalShell
|
||||||
user={{
|
user={{
|
||||||
email: user.email ?? "bilinmiyor@mindspace.local",
|
email: user.email,
|
||||||
displayName,
|
displayName,
|
||||||
shortName,
|
shortName,
|
||||||
avatarUrl: profile?.avatar_url || null,
|
avatarUrl: user.image || null,
|
||||||
}}
|
}}
|
||||||
progress={avgProgress}
|
progress={0}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PortalShell>
|
</PortalShell>
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { signup } from "@/app/login/actions";
|
import { signup } from "@/app/login/actions";
|
||||||
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
import { AuthPageShell } from "@/components/auth/auth-page-shell";
|
||||||
import { ErrorToaster } from "@/components/error-toaster";
|
import { ErrorToaster } from "@/components/error-toaster";
|
||||||
import { getFirstAdminSetupState } from "@/lib/auth/first-admin-setup";
|
import { getFirstFreelancerSetupState } from "@/server/auth/setup";
|
||||||
import { LockKeyhole, Mail, UserPlus } from "lucide-react";
|
import { LockKeyhole, Mail, UserPlus } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Button, Input, Label } from "poyraz-ui/atoms";
|
import { Input, Label } from "poyraz-ui/atoms";
|
||||||
import { SubmitButton } from "@/components/auth/submit-button";
|
import { SubmitButton } from "@/components/auth/submit-button";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function RegisterPage({
|
export default async function RegisterPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const setupState = await getFirstAdminSetupState();
|
const setupState = await getFirstFreelancerSetupState();
|
||||||
|
|
||||||
if (setupState.errorMessage) {
|
if (setupState.errorMessage) {
|
||||||
redirect(`/login?error=true&message=${encodeURIComponent(setupState.errorMessage)}`);
|
redirect(`/login?error=true&message=${encodeURIComponent(setupState.errorMessage)}`);
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
services:
|
||||||
|
neta:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATA_DIR: /app/data
|
||||||
|
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||||
|
volumes:
|
||||||
|
- neta-data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/api/health/ready').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
neta-data:
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const dataDir = process.env.DATA_DIR
|
||||||
|
? path.resolve(process.env.DATA_DIR)
|
||||||
|
: path.join(process.cwd(), ".data");
|
||||||
|
|
||||||
|
const databasePath = process.env.DATABASE_PATH
|
||||||
|
? path.resolve(process.env.DATABASE_PATH)
|
||||||
|
: path.join(dataDir, "neta.db");
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: "sqlite",
|
||||||
|
schema: "./server/db/schema/index.ts",
|
||||||
|
out: "./server/db/migrations",
|
||||||
|
dbCredentials: {
|
||||||
|
url: databasePath,
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ const withPWA = withPWAInit({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
|
output: "standalone",
|
||||||
experimental: {
|
experimental: {
|
||||||
serverActions: {
|
serverActions: {
|
||||||
bodySizeLimit: "8mb",
|
bodySizeLimit: "8mb",
|
||||||
|
|||||||
Generated
+2319
-3
File diff suppressed because it is too large
Load Diff
+13
-1
@@ -6,7 +6,14 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint ."
|
"lint": "eslint .",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:migrate": "node scripts/migrate.mjs",
|
||||||
|
"db:backup": "node scripts/backup.mjs",
|
||||||
|
"db:restore": "node scripts/restore.mjs",
|
||||||
|
"phase1:smoke": "node scripts/phase1-smoke.mjs",
|
||||||
|
"phase2:smoke": "node scripts/phase2-auth-smoke.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/google": "^3.0.80",
|
"@ai-sdk/google": "^3.0.80",
|
||||||
@@ -33,11 +40,14 @@
|
|||||||
"@supabase/supabase-js": "^2.105.3",
|
"@supabase/supabase-js": "^2.105.3",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"ai": "^6.0.197",
|
"ai": "^6.0.197",
|
||||||
|
"better-auth": "^1.6.23",
|
||||||
|
"better-sqlite3": "^12.11.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.4.0",
|
"date-fns": "^4.4.0",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"dexie-react-hooks": "^4.4.0",
|
"dexie-react-hooks": "^4.4.0",
|
||||||
|
"drizzle-orm": "^0.45.2",
|
||||||
"framer-motion": "^11.18.2",
|
"framer-motion": "^11.18.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next": "^16.2.7",
|
"next": "^16.2.7",
|
||||||
@@ -55,11 +65,13 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"@types/node": "^22.19.19",
|
"@types/node": "^22.19.19",
|
||||||
"@types/react": "^19.2.16",
|
"@types/react": "^19.2.16",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"autoprefixer": "^10.5.0",
|
"autoprefixer": "^10.5.0",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.7",
|
"eslint-config-next": "^16.2.7",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
import { updateSession } from '@/lib/supabase/middleware'
|
export function proxy(request: NextRequest) {
|
||||||
|
return NextResponse.next({
|
||||||
export async function proxy(request: NextRequest) {
|
request,
|
||||||
return updateSession(request)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!api/health|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
'/((?!api/health|api/auth|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
||||||
|
|
||||||
|
const config = ensureDataLayout();
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const backupDir = path.join(config.backupsDir, `neta-${timestamp}`);
|
||||||
|
const uploadsBackupDir = path.join(backupDir, "uploads");
|
||||||
|
const databaseBackupPath = path.join(backupDir, "neta.db");
|
||||||
|
|
||||||
|
fs.mkdirSync(backupDir, { recursive: true });
|
||||||
|
|
||||||
|
const sqlite = new Database(config.databasePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
applySqlitePragmas(sqlite);
|
||||||
|
await sqlite.backup(databaseBackupPath);
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDirectoryIfExists(config.uploadsDir, uploadsBackupDir);
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
source: {
|
||||||
|
dataDir: config.dataDir,
|
||||||
|
databasePath: config.databasePath,
|
||||||
|
uploadsDir: config.uploadsDir,
|
||||||
|
},
|
||||||
|
files: collectFiles(backupDir).map((filePath) => ({
|
||||||
|
path: path.relative(backupDir, filePath).replace(/\\/g, "/"),
|
||||||
|
bytes: fs.statSync(filePath).size,
|
||||||
|
sha256: hashFile(filePath),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(backupDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`Backup created at ${backupDir}`);
|
||||||
|
|
||||||
|
function copyDirectoryIfExists(sourceDir, targetDir) {
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDirectoryIfExists(sourcePath, targetPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFiles(rootDir) {
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
||||||
|
const entryPath = path.join(rootDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...collectFiles(entryPath));
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
files.push(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFile(filePath) {
|
||||||
|
const hash = crypto.createHash("sha256");
|
||||||
|
hash.update(fs.readFileSync(filePath));
|
||||||
|
return hash.digest("hex");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export function getDataConfig(env = process.env) {
|
||||||
|
const nodeEnv = env.NODE_ENV || "development";
|
||||||
|
const dataDir = path.resolve(
|
||||||
|
env.DATA_DIR && env.DATA_DIR.trim().length > 0
|
||||||
|
? env.DATA_DIR
|
||||||
|
: nodeEnv === "production"
|
||||||
|
? "/app/data"
|
||||||
|
: path.join(process.cwd(), ".data"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const databasePath = path.resolve(
|
||||||
|
env.DATABASE_PATH && env.DATABASE_PATH.trim().length > 0
|
||||||
|
? env.DATABASE_PATH
|
||||||
|
: path.join(dataDir, "neta.db"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dataDir,
|
||||||
|
databasePath,
|
||||||
|
uploadsDir: path.join(dataDir, "uploads"),
|
||||||
|
backupsDir: path.join(dataDir, "backups"),
|
||||||
|
tmpDir: path.join(dataDir, "tmp"),
|
||||||
|
migrationsDir: path.join(process.cwd(), "server", "db", "migrations"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDataLayout(config = getDataConfig()) {
|
||||||
|
for (const dir of [config.dataDir, config.uploadsDir, config.backupsDir, config.tmpDir]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySqlitePragmas(sqlite) {
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import { applySqlitePragmas, ensureDataLayout } from "./lib/data-dir.mjs";
|
||||||
|
|
||||||
|
export function runMigrations(databasePath) {
|
||||||
|
const config = ensureDataLayout();
|
||||||
|
const sqlite = new Database(databasePath ?? config.databasePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
applySqlitePragmas(sqlite);
|
||||||
|
const db = drizzle({ client: sqlite });
|
||||||
|
|
||||||
|
migrate(db, { migrationsFolder: config.migrationsDir });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`insert into runtime_checks (key, value, created_at, updated_at)
|
||||||
|
values (@key, @value, @createdAt, @updatedAt)
|
||||||
|
on conflict(key) do update set value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run({
|
||||||
|
key: "last_migration",
|
||||||
|
value: new Date(now).toISOString(),
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Migrations applied to ${databasePath ?? config.databasePath}`);
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
runMigrations();
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const smokeRoot = path.join(process.cwd(), ".data", `phase1-smoke-${Date.now()}`);
|
||||||
|
const restoreRoot = `${smokeRoot}-restore`;
|
||||||
|
const env = { ...process.env, DATA_DIR: smokeRoot, DATABASE_PATH: "" };
|
||||||
|
|
||||||
|
fs.mkdirSync(smokeRoot, { recursive: true });
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const dbPath = path.join(smokeRoot, "neta.db");
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const before = sqlite.prepare("select value from runtime_checks where key = ?").get("last_migration");
|
||||||
|
|
||||||
|
if (!before) {
|
||||||
|
throw new Error("Migration smoke check failed: runtime_checks row missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
sqlite
|
||||||
|
.prepare(
|
||||||
|
`insert into runtime_checks (key, value, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?)
|
||||||
|
on conflict(key) do update set value = excluded.value, updated_at = excluded.updated_at`,
|
||||||
|
)
|
||||||
|
.run("restart_probe", "persisted", now, now);
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
const reopened = new Database(dbPath, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = reopened.prepare("select value from runtime_checks where key = ?").get("restart_probe");
|
||||||
|
|
||||||
|
if (!row || row.value !== "persisted") {
|
||||||
|
throw new Error("Restart persistence smoke check failed.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reopened.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/backup.mjs"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const backupDir = fs
|
||||||
|
.readdirSync(path.join(smokeRoot, "backups"))
|
||||||
|
.map((name) => path.join(smokeRoot, "backups", name))
|
||||||
|
.sort()
|
||||||
|
.at(-1);
|
||||||
|
|
||||||
|
if (!backupDir) {
|
||||||
|
throw new Error("Backup smoke check failed: no backup directory produced.");
|
||||||
|
}
|
||||||
|
|
||||||
|
execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", restoreRoot, "--force"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: process.env,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = new Database(path.join(restoreRoot, "neta.db"), { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = restored.prepare("select value from runtime_checks where key = ?").get("restart_probe");
|
||||||
|
|
||||||
|
if (!row || row.value !== "persisted") {
|
||||||
|
throw new Error("Restore smoke check failed.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
restored.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Phase 1 smoke checks passed.");
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { ensureDataLayout } from "./lib/data-dir.mjs";
|
||||||
|
import { runMigrations } from "./migrate.mjs";
|
||||||
|
|
||||||
|
const requiredTables = [
|
||||||
|
"user",
|
||||||
|
"session",
|
||||||
|
"account",
|
||||||
|
"verification",
|
||||||
|
"app_profiles",
|
||||||
|
"app_setup_state",
|
||||||
|
"portal_invitations",
|
||||||
|
"auth_audit_events",
|
||||||
|
];
|
||||||
|
|
||||||
|
const requiredIndexes = [
|
||||||
|
"app_profiles_auth_user_id_unique",
|
||||||
|
"portal_invitations_token_hash_unique",
|
||||||
|
"session_user_id_idx",
|
||||||
|
"account_user_id_idx",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const paths = ensureDataLayout();
|
||||||
|
|
||||||
|
runMigrations(paths.databasePath);
|
||||||
|
|
||||||
|
const sqlite = new Database(paths.databasePath, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tables = sqlite
|
||||||
|
.prepare("select name from sqlite_master where type = 'table'")
|
||||||
|
.all()
|
||||||
|
.map((row) => row.name);
|
||||||
|
|
||||||
|
const indexes = sqlite
|
||||||
|
.prepare("select name from sqlite_master where type = 'index'")
|
||||||
|
.all()
|
||||||
|
.map((row) => row.name);
|
||||||
|
|
||||||
|
for (const table of requiredTables) {
|
||||||
|
assert.ok(tables.includes(table), `Missing auth table: ${table}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const index of requiredIndexes) {
|
||||||
|
assert.ok(indexes.includes(index), `Missing auth index: ${index}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileColumns = sqlite.prepare("pragma table_info(app_profiles)").all();
|
||||||
|
const roleColumn = profileColumns.find((column) => column.name === "role");
|
||||||
|
const disabledColumn = profileColumns.find((column) => column.name === "disabled");
|
||||||
|
|
||||||
|
assert.equal(roleColumn?.notnull, 1, "app_profiles.role must be required");
|
||||||
|
assert.equal(disabledColumn?.notnull, 1, "app_profiles.disabled must be required");
|
||||||
|
|
||||||
|
console.log("Phase 2 auth smoke passed");
|
||||||
|
} finally {
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
|
||||||
|
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
if (!args.from) {
|
||||||
|
throw new Error("Usage: node scripts/restore.mjs --from <backup-dir> [--target <data-dir>] [--force]");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEnv = {
|
||||||
|
...process.env,
|
||||||
|
DATA_DIR: args.target || process.env.DATA_DIR,
|
||||||
|
DATABASE_PATH: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = ensureDataLayout(getDataConfig(targetEnv));
|
||||||
|
const backupDir = path.resolve(args.from);
|
||||||
|
const backupDbPath = path.join(backupDir, "neta.db");
|
||||||
|
const backupUploadsDir = path.join(backupDir, "uploads");
|
||||||
|
|
||||||
|
if (!fs.existsSync(backupDbPath)) {
|
||||||
|
throw new Error(`Backup database not found: ${backupDbPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(config.databasePath) && !args.force) {
|
||||||
|
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.copyFileSync(backupDbPath, config.databasePath);
|
||||||
|
|
||||||
|
if (fs.existsSync(backupUploadsDir)) {
|
||||||
|
fs.rmSync(config.uploadsDir, { recursive: true, force: true });
|
||||||
|
copyDirectory(backupUploadsDir, config.uploadsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Backup restored from ${backupDir} to ${config.dataDir}`);
|
||||||
|
|
||||||
|
function parseArgs(values) {
|
||||||
|
const parsed = { force: false };
|
||||||
|
|
||||||
|
for (let index = 0; index < values.length; index += 1) {
|
||||||
|
const value = values[index];
|
||||||
|
|
||||||
|
if (value === "--force") {
|
||||||
|
parsed.force = true;
|
||||||
|
} else if (value === "--from") {
|
||||||
|
parsed.from = values[index + 1];
|
||||||
|
index += 1;
|
||||||
|
} else if (value === "--target") {
|
||||||
|
parsed.target = values[index + 1];
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyDirectory(sourceDir, targetDir) {
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||||
|
const sourcePath = path.join(sourceDir, entry.name);
|
||||||
|
const targetPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
copyDirectory(sourcePath, targetPath);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
fs.copyFileSync(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { nextCookies } from "better-auth/next-js";
|
||||||
|
import { getServerConfig } from "@/server/config";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import * as schema from "@/server/db/schema";
|
||||||
|
import {
|
||||||
|
completeFirstFreelancerSetup,
|
||||||
|
recordAuthAuditEvent,
|
||||||
|
reserveFirstFreelancerSetup,
|
||||||
|
} from "@/server/auth/setup";
|
||||||
|
|
||||||
|
const config = getServerConfig();
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
appName: "Neta",
|
||||||
|
baseURL: config.appUrl,
|
||||||
|
trustedOrigins: config.trustedOrigins,
|
||||||
|
secret: config.betterAuthSecret,
|
||||||
|
database: drizzleAdapter(getSqliteConnection().db, {
|
||||||
|
provider: "sqlite",
|
||||||
|
schema,
|
||||||
|
transaction: true,
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
minPasswordLength: 8,
|
||||||
|
maxPasswordLength: 128,
|
||||||
|
requireEmailVerification: false,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
window: 60,
|
||||||
|
max: 60,
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/email": {
|
||||||
|
window: 60,
|
||||||
|
max: 10,
|
||||||
|
},
|
||||||
|
"/sign-up/email": {
|
||||||
|
window: 300,
|
||||||
|
max: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
useSecureCookies: config.nodeEnv === "production",
|
||||||
|
cookiePrefix: "neta",
|
||||||
|
defaultCookieAttributes: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: config.nodeEnv === "production",
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
before: async (user) => {
|
||||||
|
const isReserved = await reserveFirstFreelancerSetup(user.email);
|
||||||
|
return isReserved;
|
||||||
|
},
|
||||||
|
after: async (user) => {
|
||||||
|
await completeFirstFreelancerSetup(user);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: {
|
||||||
|
after: async (session) => {
|
||||||
|
await recordAuthAuditEvent({
|
||||||
|
type: "login_succeeded",
|
||||||
|
authUserId: session.userId,
|
||||||
|
metadata: { source: "session_create" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
after: async (session) => {
|
||||||
|
await recordAuthAuditEvent({
|
||||||
|
type: "logout_succeeded",
|
||||||
|
authUserId: session.userId,
|
||||||
|
metadata: { source: "session_delete" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [nextCookies()],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Auth = typeof auth;
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import type { SessionContext } from "@/server/auth/session";
|
||||||
|
import type { UserRole } from "@/server/auth/types";
|
||||||
|
|
||||||
|
export class AuthorizationError extends Error {
|
||||||
|
constructor(
|
||||||
|
message = "Bu işlem için yetkiniz yok.",
|
||||||
|
public readonly code: "UNAUTHENTICATED" | "FORBIDDEN" | "NOT_FOUND" = "FORBIDDEN",
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AuthorizationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertRole(context: SessionContext | null, allowedRoles: readonly UserRole[]): void {
|
||||||
|
if (!context) {
|
||||||
|
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(context.profile.role)) {
|
||||||
|
throw new AuthorizationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSameOwner(context: SessionContext | null, ownerAuthUserId: string): void {
|
||||||
|
if (!context) {
|
||||||
|
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.user.id !== ownerAuthUserId) {
|
||||||
|
throw new AuthorizationError("Kaynak bulunamadı.", "NOT_FOUND");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEnabledUser(context: SessionContext | null): void {
|
||||||
|
if (!context) {
|
||||||
|
throw new AuthorizationError("Oturum gerekli.", "UNAUTHENTICATED");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.profile.disabled) {
|
||||||
|
throw new AuthorizationError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
|
import { auth } from "@/server/auth/auth";
|
||||||
|
import type { UserRole } from "@/server/auth/types";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { appProfiles } from "@/server/db/schema";
|
||||||
|
|
||||||
|
type BetterAuthSession = NonNullable<Awaited<ReturnType<typeof auth.api.getSession>>>;
|
||||||
|
|
||||||
|
export type SessionContext = {
|
||||||
|
session: BetterAuthSession["session"];
|
||||||
|
user: BetterAuthSession["user"];
|
||||||
|
profile: {
|
||||||
|
id: number;
|
||||||
|
authUserId: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: UserRole;
|
||||||
|
disabled: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionContext = cache(async (): Promise<SessionContext | null> => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
query: {
|
||||||
|
disableCookieCache: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = getProfileByAuthUserId(session.user.id);
|
||||||
|
|
||||||
|
if (!profile || profile.disabled || profile.authUserId !== session.user.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session: session.session,
|
||||||
|
user: session.user,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function requireSession(): Promise<SessionContext> {
|
||||||
|
const context = await getSessionContext();
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireFreelancer(): Promise<SessionContext> {
|
||||||
|
const context = await requireSession();
|
||||||
|
|
||||||
|
if (context.profile.role !== "freelancer") {
|
||||||
|
redirect("/portal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireClientUser(): Promise<SessionContext> {
|
||||||
|
const context = await requireSession();
|
||||||
|
|
||||||
|
if (context.profile.role !== "client") {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProfileByAuthUserId(authUserId: string): SessionContext["profile"] | null {
|
||||||
|
const { db } = getSqliteConnection();
|
||||||
|
const [profile] = db
|
||||||
|
.select({
|
||||||
|
id: appProfiles.id,
|
||||||
|
authUserId: appProfiles.authUserId,
|
||||||
|
email: appProfiles.email,
|
||||||
|
displayName: appProfiles.displayName,
|
||||||
|
role: appProfiles.role,
|
||||||
|
disabled: appProfiles.disabled,
|
||||||
|
})
|
||||||
|
.from(appProfiles)
|
||||||
|
.where(eq(appProfiles.authUserId, authUserId))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return profile ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { count, eq } from "drizzle-orm";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
import { appProfiles, appSetupState, authAuditEvents } from "@/server/db/schema";
|
||||||
|
import type { AuthAuditEventType } from "@/server/auth/types";
|
||||||
|
import { getDefaultDisplayName, normalizeAuthEmail } from "@/server/auth/validation";
|
||||||
|
|
||||||
|
const FIRST_FREELANCER_SETUP_KEY = "first_freelancer";
|
||||||
|
const SETUP_LOCK_TTL_MS = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
export type FirstFreelancerSetupState = {
|
||||||
|
available: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getFirstFreelancerSetupState(): Promise<FirstFreelancerSetupState> {
|
||||||
|
try {
|
||||||
|
return readFirstFreelancerSetupState();
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
locked: false,
|
||||||
|
errorMessage:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "İlk kurulum durumu okunamadı.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readFirstFreelancerSetupState(): FirstFreelancerSetupState {
|
||||||
|
const { db } = getSqliteConnection();
|
||||||
|
const [{ value: freelancerCount }] = db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(appProfiles)
|
||||||
|
.where(eq(appProfiles.role, "freelancer"))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (freelancerCount > 0) {
|
||||||
|
return { available: false, locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [setupState] = db
|
||||||
|
.select()
|
||||||
|
.from(appSetupState)
|
||||||
|
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (!setupState) {
|
||||||
|
return { available: true, locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupState.status === "completed") {
|
||||||
|
return { available: false, locked: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
|
||||||
|
const isStale = Date.now() - lockedAt > SETUP_LOCK_TTL_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: isStale,
|
||||||
|
locked: !isStale,
|
||||||
|
errorMessage: isStale ? undefined : "İlk kurulum şu anda başka bir istek tarafından işleniyor.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reserveFirstFreelancerSetup(email: string): Promise<boolean> {
|
||||||
|
const normalizedEmail = normalizeAuthEmail(email);
|
||||||
|
const { db } = getSqliteConnection();
|
||||||
|
|
||||||
|
return db.transaction((tx) => {
|
||||||
|
const [{ value: freelancerCount }] = tx
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(appProfiles)
|
||||||
|
.where(eq(appProfiles.role, "freelancer"))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (freelancerCount > 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [setupState] = tx
|
||||||
|
.select()
|
||||||
|
.from(appSetupState)
|
||||||
|
.where(eq(appSetupState.key, FIRST_FREELANCER_SETUP_KEY))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (setupState?.status === "completed") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setupState?.status === "pending") {
|
||||||
|
const lockedAt = setupState.lockedAt?.getTime() ?? 0;
|
||||||
|
|
||||||
|
if (Date.now() - lockedAt <= SETUP_LOCK_TTL_MS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.insert(appSetupState)
|
||||||
|
.values({
|
||||||
|
key: FIRST_FREELANCER_SETUP_KEY,
|
||||||
|
status: "pending",
|
||||||
|
lockedBy: normalizedEmail,
|
||||||
|
lockedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: appSetupState.key,
|
||||||
|
set: {
|
||||||
|
status: "pending",
|
||||||
|
lockedBy: normalizedEmail,
|
||||||
|
lockedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
tx.insert(authAuditEvents)
|
||||||
|
.values({
|
||||||
|
type: "setup_started",
|
||||||
|
email: normalizedEmail,
|
||||||
|
metadata: { source: "better_auth_user_create" },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeFirstFreelancerSetup(user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const normalizedEmail = normalizeAuthEmail(user.email);
|
||||||
|
const now = new Date();
|
||||||
|
const { db } = getSqliteConnection();
|
||||||
|
|
||||||
|
db.transaction((tx) => {
|
||||||
|
tx.insert(appProfiles)
|
||||||
|
.values({
|
||||||
|
authUserId: user.id,
|
||||||
|
email: normalizedEmail,
|
||||||
|
displayName: user.name || getDefaultDisplayName(normalizedEmail),
|
||||||
|
role: "freelancer",
|
||||||
|
disabled: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing()
|
||||||
|
.run();
|
||||||
|
|
||||||
|
tx.insert(appSetupState)
|
||||||
|
.values({
|
||||||
|
key: FIRST_FREELANCER_SETUP_KEY,
|
||||||
|
status: "completed",
|
||||||
|
lockedBy: normalizedEmail,
|
||||||
|
lockedAt: now,
|
||||||
|
completedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: appSetupState.key,
|
||||||
|
set: {
|
||||||
|
status: "completed",
|
||||||
|
lockedBy: normalizedEmail,
|
||||||
|
completedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
tx.insert(authAuditEvents)
|
||||||
|
.values({
|
||||||
|
type: "setup_completed",
|
||||||
|
authUserId: user.id,
|
||||||
|
email: normalizedEmail,
|
||||||
|
metadata: { role: "freelancer" },
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordAuthAuditEvent(input: {
|
||||||
|
type: AuthAuditEventType;
|
||||||
|
authUserId?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
metadata?: Record<string, unknown> | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const { db } = getSqliteConnection();
|
||||||
|
|
||||||
|
db.insert(authAuditEvents)
|
||||||
|
.values({
|
||||||
|
type: input.type,
|
||||||
|
authUserId: input.authUserId ?? null,
|
||||||
|
email: input.email ? normalizeAuthEmail(input.email) : null,
|
||||||
|
metadata: input.metadata ?? null,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const userRoles = ["freelancer", "client"] as const;
|
||||||
|
|
||||||
|
export type UserRole = (typeof userRoles)[number];
|
||||||
|
|
||||||
|
export type SetupStatus = "pending" | "completed";
|
||||||
|
|
||||||
|
export type AuthAuditEventType =
|
||||||
|
| "setup_started"
|
||||||
|
| "setup_completed"
|
||||||
|
| "login_succeeded"
|
||||||
|
| "login_failed"
|
||||||
|
| "logout_succeeded";
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const authCredentialsSchema = z.object({
|
||||||
|
email: z.email().transform((value) => normalizeAuthEmail(value)),
|
||||||
|
password: z.string().min(8).max(128),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AuthCredentials = z.infer<typeof authCredentialsSchema>;
|
||||||
|
|
||||||
|
export function parseAuthCredentials(formData: FormData): AuthCredentials {
|
||||||
|
return authCredentialsSchema.parse({
|
||||||
|
email: formData.get("email"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAuthEmail(value: string): string {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultDisplayName(email: string): string {
|
||||||
|
return email.split("@")[0] || "Neta Kullanıcısı";
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||||
|
APP_URL: z.string().trim().optional(),
|
||||||
|
NEXT_PUBLIC_SITE_URL: z.string().trim().optional(),
|
||||||
|
BETTER_AUTH_URL: z.string().trim().optional(),
|
||||||
|
BETTER_AUTH_SECRET: z.string().trim().optional(),
|
||||||
|
TRUSTED_ORIGINS: z.string().trim().optional(),
|
||||||
|
DATA_DIR: z.string().trim().optional(),
|
||||||
|
DATABASE_PATH: z.string().trim().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ServerConfig = {
|
||||||
|
nodeEnv: "development" | "test" | "production";
|
||||||
|
dataDir: string;
|
||||||
|
databasePath: string;
|
||||||
|
uploadsDir: string;
|
||||||
|
backupsDir: string;
|
||||||
|
tmpDir: string;
|
||||||
|
appUrl: string;
|
||||||
|
trustedOrigins: string[];
|
||||||
|
betterAuthSecret?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedConfig: ServerConfig | undefined;
|
||||||
|
|
||||||
|
export function getServerConfig(): ServerConfig {
|
||||||
|
if (cachedConfig) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = envSchema.parse(process.env);
|
||||||
|
const dataDir = path.resolve(
|
||||||
|
parsed.DATA_DIR && parsed.DATA_DIR.length > 0
|
||||||
|
? parsed.DATA_DIR
|
||||||
|
: parsed.NODE_ENV === "production"
|
||||||
|
? "/app/data"
|
||||||
|
: path.join(process.cwd(), ".data"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const databasePath = path.resolve(
|
||||||
|
parsed.DATABASE_PATH && parsed.DATABASE_PATH.length > 0
|
||||||
|
? parsed.DATABASE_PATH
|
||||||
|
: path.join(dataDir, "neta.db"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const appUrl = normalizeOrigin(
|
||||||
|
parsed.BETTER_AUTH_URL ||
|
||||||
|
parsed.APP_URL ||
|
||||||
|
parsed.NEXT_PUBLIC_SITE_URL ||
|
||||||
|
"http://localhost:3000",
|
||||||
|
);
|
||||||
|
const trustedOrigins = normalizeTrustedOrigins(parsed.TRUSTED_ORIGINS, appUrl);
|
||||||
|
const betterAuthSecret = normalizeAuthSecret(parsed.BETTER_AUTH_SECRET, parsed.NODE_ENV);
|
||||||
|
|
||||||
|
cachedConfig = {
|
||||||
|
nodeEnv: parsed.NODE_ENV,
|
||||||
|
dataDir,
|
||||||
|
databasePath,
|
||||||
|
uploadsDir: path.join(dataDir, "uploads"),
|
||||||
|
backupsDir: path.join(dataDir, "backups"),
|
||||||
|
tmpDir: path.join(dataDir, "tmp"),
|
||||||
|
appUrl,
|
||||||
|
trustedOrigins,
|
||||||
|
betterAuthSecret,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(value: string): string {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrustedOrigins(value: string | undefined, appUrl: string): string[] {
|
||||||
|
const origins = new Set([appUrl]);
|
||||||
|
|
||||||
|
for (const rawOrigin of value?.split(",") ?? []) {
|
||||||
|
const origin = rawOrigin.trim();
|
||||||
|
|
||||||
|
if (!origin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin.includes("*")) {
|
||||||
|
throw new Error("TRUSTED_ORIGINS wildcard icermemelidir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
origins.add(normalizeOrigin(origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...origins];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAuthSecret(
|
||||||
|
value: string | undefined,
|
||||||
|
nodeEnv: ServerConfig["nodeEnv"],
|
||||||
|
): string | undefined {
|
||||||
|
if (value && value.length < 32) {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET en az 32 karakter olmalidir.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeEnv === "production" && process.env.NEXT_PHASE !== "phase-production-build") {
|
||||||
|
throw new Error("BETTER_AUTH_SECRET production runtime icin zorunludur.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NEXT_PHASE === "phase-production-build") {
|
||||||
|
return "build-time-placeholder-do-not-use-at-runtime";
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureDataDirectories(config = getServerConfig()): void {
|
||||||
|
for (const dir of [config.dataDir, config.uploadsDir, config.backupsDir, config.tmpDir]) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { ensureDataDirectories, getServerConfig } from "@/server/config";
|
||||||
|
import * as schema from "@/server/db/schema";
|
||||||
|
|
||||||
|
export type SqliteConnection = {
|
||||||
|
sqlite: Database.Database;
|
||||||
|
db: BetterSQLite3Database<typeof schema>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const globalForSqlite = globalThis as typeof globalThis & {
|
||||||
|
__netaSqliteConnection?: SqliteConnection;
|
||||||
|
__netaSqliteCloseHandlersRegistered?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSqliteConnection(): SqliteConnection {
|
||||||
|
if (globalForSqlite.__netaSqliteConnection) {
|
||||||
|
return globalForSqlite.__netaSqliteConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getServerConfig();
|
||||||
|
ensureDataDirectories(config);
|
||||||
|
|
||||||
|
const sqlite = new Database(config.databasePath);
|
||||||
|
applyPragmas(sqlite);
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
sqlite,
|
||||||
|
db: drizzle({ client: sqlite, schema }),
|
||||||
|
};
|
||||||
|
|
||||||
|
globalForSqlite.__netaSqliteConnection = connection;
|
||||||
|
registerCloseHandlers();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeSqliteConnection(): void {
|
||||||
|
const connection = globalForSqlite.__netaSqliteConnection;
|
||||||
|
|
||||||
|
if (!connection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.sqlite.close();
|
||||||
|
globalForSqlite.__netaSqliteConnection = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPragmas(sqlite: Database.Database): void {
|
||||||
|
sqlite.pragma("foreign_keys = ON");
|
||||||
|
sqlite.pragma("journal_mode = WAL");
|
||||||
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCloseHandlers(): void {
|
||||||
|
if (globalForSqlite.__netaSqliteCloseHandlersRegistered || process.env.NODE_ENV !== "production") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.once("beforeExit", closeSqliteConnection);
|
||||||
|
globalForSqlite.__netaSqliteCloseHandlersRegistered = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { ensureDataDirectories, getServerConfig } from "@/server/config";
|
||||||
|
import { getSqliteConnection } from "@/server/db/client";
|
||||||
|
|
||||||
|
export type ReadinessStatus = {
|
||||||
|
ok: boolean;
|
||||||
|
checks: {
|
||||||
|
dataDirWritable: boolean;
|
||||||
|
databaseReachable: boolean;
|
||||||
|
migrationsApplied: boolean;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function checkReadiness(): ReadinessStatus {
|
||||||
|
const config = getServerConfig();
|
||||||
|
const checks = {
|
||||||
|
dataDirWritable: false,
|
||||||
|
databaseReachable: false,
|
||||||
|
migrationsApplied: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
ensureDataDirectories(config);
|
||||||
|
assertWritableDirectory(config.dataDir);
|
||||||
|
checks.dataDirWritable = true;
|
||||||
|
|
||||||
|
const { sqlite } = getSqliteConnection();
|
||||||
|
sqlite.prepare("select 1 as ok").get();
|
||||||
|
checks.databaseReachable = true;
|
||||||
|
|
||||||
|
const migrationRow = sqlite
|
||||||
|
.prepare("select name from sqlite_master where type = 'table' and name = 'runtime_checks'")
|
||||||
|
.get();
|
||||||
|
checks.migrationsApplied = Boolean(migrationRow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: Boolean(migrationRow),
|
||||||
|
checks,
|
||||||
|
error: migrationRow ? undefined : "Migrations have not been applied.",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
checks,
|
||||||
|
error: error instanceof Error ? error.message : "Unknown readiness error.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWritableDirectory(dir: string): void {
|
||||||
|
const probePath = path.join(dir, `.neta-write-${process.pid}-${Date.now()}`);
|
||||||
|
|
||||||
|
fs.writeFileSync(probePath, "ok", { encoding: "utf8", flag: "wx" });
|
||||||
|
fs.unlinkSync(probePath);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE `runtime_checks` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `runtime_events` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`message` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
CREATE TABLE `account` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`account_id` text NOT NULL,
|
||||||
|
`provider_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`access_token` text,
|
||||||
|
`refresh_token` text,
|
||||||
|
`id_token` text,
|
||||||
|
`access_token_expires_at` integer,
|
||||||
|
`refresh_token_expires_at` integer,
|
||||||
|
`scope` text,
|
||||||
|
`password` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `account_user_id_idx` ON `account` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `app_profiles` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`auth_user_id` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`display_name` text NOT NULL,
|
||||||
|
`role` text NOT NULL,
|
||||||
|
`disabled` integer DEFAULT false NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`auth_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `app_profiles_auth_user_id_unique` ON `app_profiles` (`auth_user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `app_profiles_role_idx` ON `app_profiles` (`role`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `app_setup_state` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`status` text NOT NULL,
|
||||||
|
`locked_by` text,
|
||||||
|
`locked_at` integer,
|
||||||
|
`completed_at` integer,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE `auth_audit_events` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`type` text NOT NULL,
|
||||||
|
`auth_user_id` text,
|
||||||
|
`email` text,
|
||||||
|
`metadata` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`auth_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `auth_audit_events_type_idx` ON `auth_audit_events` (`type`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `auth_audit_events_auth_user_id_idx` ON `auth_audit_events` (`auth_user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `portal_invitations` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`token_hash` text NOT NULL,
|
||||||
|
`client_id` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`status` text DEFAULT 'pending' NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`accepted_at` integer,
|
||||||
|
`created_by_user_id` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
FOREIGN KEY (`created_by_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `portal_invitations_token_hash_unique` ON `portal_invitations` (`token_hash`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `portal_invitations_client_id_idx` ON `portal_invitations` (`client_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `portal_invitations_email_idx` ON `portal_invitations` (`email`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `session` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`token` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer NOT NULL,
|
||||||
|
`ip_address` text,
|
||||||
|
`user_agent` text,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `session_user_id_idx` ON `session` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `user` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`email` text NOT NULL,
|
||||||
|
`email_verified` integer DEFAULT false NOT NULL,
|
||||||
|
`image` text,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `verification` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`identifier` text NOT NULL,
|
||||||
|
`value` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `verification_identifier_idx` ON `verification` (`identifier`);
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "edc570f7-a411-4b4b-901e-94d53532fd37",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"tables": {
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "cb8d4285-951f-4bbf-b848-953bdf769836",
|
||||||
|
"prevId": "edc570f7-a411-4b4b-901e-94d53532fd37",
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"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_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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783708523046,
|
||||||
|
"tag": "0000_wise_reaper",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1783709956320,
|
||||||
|
"tag": "0001_silky_jetstream",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import { index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||||
|
import type { AuthAuditEventType, SetupStatus, UserRole } from "@/server/auth/types";
|
||||||
|
|
||||||
|
const nowMs = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||||
|
|
||||||
|
export const user = sqliteTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: integer("email_verified", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
image: text("image"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const session = sqliteTable(
|
||||||
|
"session",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
},
|
||||||
|
(table) => [index("session_user_id_idx").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const account = sqliteTable(
|
||||||
|
"account",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp_ms" }),
|
||||||
|
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp_ms" }),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("account_user_id_idx").on(table.userId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const verification = sqliteTable(
|
||||||
|
"verification",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const appProfiles = sqliteTable(
|
||||||
|
"app_profiles",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
authUserId: text("auth_user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
role: text("role").$type<UserRole>().notNull(),
|
||||||
|
disabled: integer("disabled", { mode: "boolean" }).default(false).notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("app_profiles_auth_user_id_unique").on(table.authUserId),
|
||||||
|
index("app_profiles_role_idx").on(table.role),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const appSetupState = sqliteTable("app_setup_state", {
|
||||||
|
key: text("key").primaryKey(),
|
||||||
|
status: text("status").$type<SetupStatus>().notNull(),
|
||||||
|
lockedBy: text("locked_by"),
|
||||||
|
lockedAt: integer("locked_at", { mode: "timestamp_ms" }),
|
||||||
|
completedAt: integer("completed_at", { mode: "timestamp_ms" }),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
|
||||||
|
.default(nowMs)
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const portalInvitations = sqliteTable(
|
||||||
|
"portal_invitations",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
tokenHash: text("token_hash").notNull(),
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
email: text("email").notNull(),
|
||||||
|
status: text("status", { enum: ["pending", "accepted", "revoked", "expired"] })
|
||||||
|
.default("pending")
|
||||||
|
.notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
acceptedAt: integer("accepted_at", { mode: "timestamp_ms" }),
|
||||||
|
createdByUserId: text("created_by_user_id").references(() => user.id, { onDelete: "set null" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("portal_invitations_token_hash_unique").on(table.tokenHash),
|
||||||
|
index("portal_invitations_client_id_idx").on(table.clientId),
|
||||||
|
index("portal_invitations_email_idx").on(table.email),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const authAuditEvents = sqliteTable(
|
||||||
|
"auth_audit_events",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
type: text("type").$type<AuthAuditEventType>().notNull(),
|
||||||
|
authUserId: text("auth_user_id").references(() => user.id, { onDelete: "set null" }),
|
||||||
|
email: text("email"),
|
||||||
|
metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown> | null>(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).default(nowMs).notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("auth_audit_events_type_idx").on(table.type),
|
||||||
|
index("auth_audit_events_auth_user_id_idx").on(table.authUserId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./auth";
|
||||||
|
export * from "./runtime";
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const runtimeChecks = sqliteTable("runtime_checks", {
|
||||||
|
key: text("key").primaryKey(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const runtimeEvents = sqliteTable("runtime_events", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
type: text("type").notNull(),
|
||||||
|
message: text("message").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import "server-only";
|
||||||
|
|
||||||
|
import { getSqliteConnection, type SqliteConnection } from "@/server/db/client";
|
||||||
|
|
||||||
|
let transactionDepth = 0;
|
||||||
|
|
||||||
|
export function runInTransaction<T>(operation: (connection: SqliteConnection) => T): T {
|
||||||
|
const connection = getSqliteConnection();
|
||||||
|
|
||||||
|
if (transactionDepth > 0) {
|
||||||
|
return operation(connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
const execute = connection.sqlite.transaction(() => {
|
||||||
|
transactionDepth += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return operation(connection);
|
||||||
|
} finally {
|
||||||
|
transactionDepth -= 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return execute();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user