feat: add better auth sqlite runtime and server-side session flow
This commit is contained in:
@@ -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