feat: add better-auth api-key plugin, import endpoint, and API key UI
This commit is contained in:
@@ -50,6 +50,8 @@ import { db } from "@/lib/db"
|
||||
import { user } from "@/lib/db/schema/auth"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { mobileRoutes } from "@/lib/api/mobile"
|
||||
import { gamesLookupRoutes } from "@/lib/api/games-lookup"
|
||||
import { performanceImportRoutes } from "@/lib/api/performance-import"
|
||||
|
||||
const betterAuth = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
@@ -241,6 +243,7 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
.use(standaloneVersionTestRoutes)
|
||||
.use(gamesManualRoutes)
|
||||
.use(screenshotRoutes)
|
||||
.use(gamesLookupRoutes)
|
||||
.use(mobileRoutes)
|
||||
)
|
||||
// ── Write routes ───────────────────────────────────────────
|
||||
@@ -256,6 +259,7 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
.use(adminPerformanceRoutes)
|
||||
.use(adminCommentRoutes)
|
||||
.use(adminStorageRoutes)
|
||||
.use(performanceImportRoutes)
|
||||
.use(adminAnalyticsRoutes)
|
||||
)
|
||||
// ── Public forms (no auth) ─────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { games, gameVersions } from "@/lib/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
export const gamesLookupRoutes = new Elysia({
|
||||
prefix: "/games",
|
||||
detail: { tags: ["Games"] },
|
||||
}).get(
|
||||
"/lookup",
|
||||
async ({ query, set }) => {
|
||||
const { steamAppId } = query
|
||||
|
||||
if (!steamAppId) {
|
||||
set.status = 400
|
||||
return { error: "steamAppId query parameter is required" }
|
||||
}
|
||||
|
||||
// Look up the game
|
||||
const [game] = await db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
slug: games.slug,
|
||||
headerImage: games.headerImage,
|
||||
capsuleImage: games.capsuleImage,
|
||||
developer: games.developer,
|
||||
publisher: games.publisher,
|
||||
source: games.source,
|
||||
})
|
||||
.from(games)
|
||||
.where(eq(games.steamAppId, steamAppId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return { error: `No game found with steamAppId ${steamAppId}` }
|
||||
}
|
||||
|
||||
// Find the latest version
|
||||
const [latestVersion] = await db
|
||||
.select({
|
||||
id: gameVersions.id,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
isLatest: gameVersions.isLatest,
|
||||
createdAt: gameVersions.createdAt,
|
||||
})
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, game.id),
|
||||
eq(gameVersions.isLatest, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
// If no latest version, get the most recent one
|
||||
const version =
|
||||
latestVersion ??
|
||||
(await db
|
||||
.select({
|
||||
id: gameVersions.id,
|
||||
versionString: gameVersions.versionString,
|
||||
buildId: gameVersions.buildId,
|
||||
isLatest: gameVersions.isLatest,
|
||||
createdAt: gameVersions.createdAt,
|
||||
})
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, game.id))
|
||||
.orderBy(gameVersions.createdAt)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null))
|
||||
|
||||
return {
|
||||
game: {
|
||||
...game,
|
||||
steamAppId: game.steamAppId ?? null,
|
||||
},
|
||||
version,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
steamAppId: t.Numeric(),
|
||||
}),
|
||||
detail: {
|
||||
description:
|
||||
"Look up a game and its latest version by Steam App ID. Used by the DeckyVault Decky Loader plugin to resolve game info before importing benchmarks.",
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -30,3 +30,5 @@ export { dashboardPublicRoutes } from "./dashboard-public"
|
||||
export { cronRoutes } from "./cron"
|
||||
export { profilePhotoRoutes } from "./profile-photo"
|
||||
export { mobileRoutes } from "./mobile"
|
||||
export { gamesLookupRoutes } from "./games-lookup"
|
||||
export { performanceImportRoutes } from "./performance-import"
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
performanceEntries,
|
||||
gameVersions,
|
||||
games,
|
||||
hardware,
|
||||
gamePlatformSupport,
|
||||
} from "@/lib/db/schema"
|
||||
import { eq, and } from "drizzle-orm"
|
||||
import { requireAuthWithApiKeyFallback } from "@/lib/auth/api-key-guard"
|
||||
import { recalculatePlayability } from "./playability"
|
||||
|
||||
const VALID_UPSCALER_TYPES = ["none", "fsr", "dlss", "xess", "lsfg", "other"] as const
|
||||
const VALID_FRAME_GEN_METHODS = ["none", "fsr_fg", "dlss_fg", "lsfg", "other"] as const
|
||||
const VALID_ANTICHEAT_STATUSES = ["none", "supported", "unsupported", "unknown"] as const
|
||||
|
||||
type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
|
||||
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
|
||||
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number]
|
||||
|
||||
export const performanceImportRoutes = new Elysia({
|
||||
prefix: "/performance",
|
||||
detail: { tags: ["Performance"] },
|
||||
}).post(
|
||||
"/import",
|
||||
async ({ body, request, set }) => {
|
||||
// ── Auth: session or API key ──────────────────────────────────
|
||||
const guard = await requireAuthWithApiKeyFallback(request.headers)
|
||||
if (!guard.ok) {
|
||||
set.status = guard.status
|
||||
return { error: guard.error }
|
||||
}
|
||||
|
||||
// ── Validate version (1 only for now) ─────────────────────────
|
||||
if (body.version !== 1) {
|
||||
set.status = 400
|
||||
return { error: "Unsupported import format version" }
|
||||
}
|
||||
|
||||
// ── Resolve game version from steamAppId ──────────────────────
|
||||
const steamAppId = body.steamAppId
|
||||
if (!steamAppId) {
|
||||
set.status = 400
|
||||
return { error: "steamAppId is required" }
|
||||
}
|
||||
|
||||
const [game] = await db
|
||||
.select({ id: games.id })
|
||||
.from(games)
|
||||
.where(eq(games.steamAppId, steamAppId))
|
||||
.limit(1)
|
||||
|
||||
if (!game) {
|
||||
set.status = 404
|
||||
return {
|
||||
error: `No game found with steamAppId ${steamAppId}. Submit the game on DeckyVault first.`,
|
||||
}
|
||||
}
|
||||
|
||||
// Find the latest version, or create one if needed
|
||||
let [version] = await db
|
||||
.select({ id: gameVersions.id })
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(gameVersions.gameId, game.id),
|
||||
eq(gameVersions.isLatest, true),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (!version) {
|
||||
// Get the most recent version
|
||||
const [existing] = await db
|
||||
.select({ id: gameVersions.id })
|
||||
.from(gameVersions)
|
||||
.where(eq(gameVersions.gameId, game.id))
|
||||
.orderBy(gameVersions.createdAt)
|
||||
.limit(1)
|
||||
|
||||
if (existing) {
|
||||
version = existing
|
||||
} else {
|
||||
// Create a stub version so we can create the entry
|
||||
const [newVersion] = await db
|
||||
.insert(gameVersions)
|
||||
.values({
|
||||
gameId: game.id,
|
||||
isLatest: true,
|
||||
})
|
||||
.returning({ id: gameVersions.id })
|
||||
version = newVersion
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate hardware ─────────────────────────────────────────
|
||||
const hardwareSlug = body.hardwareSlug
|
||||
const [device] = await db
|
||||
.select({ slug: hardware.slug, deviceType: hardware.deviceType })
|
||||
.from(hardware)
|
||||
.where(eq(hardware.slug, hardwareSlug))
|
||||
.limit(1)
|
||||
|
||||
if (!device) {
|
||||
set.status = 400
|
||||
return {
|
||||
error: `Unknown hardware slug: "${hardwareSlug}". Available devices: see /api/hardware`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate FPS fields ───────────────────────────────────────
|
||||
const fpsAvg = Number(body.fpsAvg)
|
||||
if (isNaN(fpsAvg) || fpsAvg < 1 || fpsAvg > 500) {
|
||||
set.status = 400
|
||||
return { error: "fpsAvg must be between 1 and 500" }
|
||||
}
|
||||
|
||||
const fpsLow = body.fpsLow != null ? Number(body.fpsLow) : null
|
||||
if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < 0 || fpsLow > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsLow must be between 0 and 500" }
|
||||
}
|
||||
|
||||
const fpsOnePercentLow =
|
||||
body.fpsOnePercentLow != null ? Number(body.fpsOnePercentLow) : null
|
||||
if (
|
||||
fpsOnePercentLow !== null &&
|
||||
(isNaN(fpsOnePercentLow) || fpsOnePercentLow < 0 || fpsOnePercentLow > 500)
|
||||
) {
|
||||
set.status = 400
|
||||
return { error: "fpsOnePercentLow must be between 0 and 500" }
|
||||
}
|
||||
|
||||
const fpsHigh = body.fpsHigh != null ? Number(body.fpsHigh) : null
|
||||
if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < 0 || fpsHigh > 500)) {
|
||||
set.status = 400
|
||||
return { error: "fpsHigh must be between 0 and 500" }
|
||||
}
|
||||
|
||||
// ── Validate enums ────────────────────────────────────────────
|
||||
const rawUpscalerType = body.upscalerType ?? "none"
|
||||
const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes(
|
||||
rawUpscalerType as UpscalerType,
|
||||
)
|
||||
? (rawUpscalerType as UpscalerType)
|
||||
: "none"
|
||||
|
||||
const rawFrameGenMethod = body.frameGenMethod ?? "none"
|
||||
const frameGenMethod: FrameGenMethod = VALID_FRAME_GEN_METHODS.includes(
|
||||
rawFrameGenMethod as FrameGenMethod,
|
||||
)
|
||||
? (rawFrameGenMethod as FrameGenMethod)
|
||||
: "none"
|
||||
|
||||
// ── Validate other numeric fields ─────────────────────────────
|
||||
const tdpWatts = body.tdpWatts != null ? Number(body.tdpWatts) : null
|
||||
if (tdpWatts !== null && (isNaN(tdpWatts) || tdpWatts <= 0)) {
|
||||
set.status = 400
|
||||
return { error: "tdpWatts must be greater than 0" }
|
||||
}
|
||||
|
||||
const loadTimeSsd = body.loadTimeSsd != null ? Number(body.loadTimeSsd) : null
|
||||
const loadTimeSd = body.loadTimeSd != null ? Number(body.loadTimeSd) : null
|
||||
|
||||
// ── Validate userNotes length ─────────────────────────────────
|
||||
const userNotes = body.userNotes ?? null
|
||||
if (userNotes && typeof userNotes === "string" && userNotes.length > 5000) {
|
||||
set.status = 400
|
||||
return { error: "userNotes must be 5000 characters or less" }
|
||||
}
|
||||
|
||||
// ── Create the performance entry ──────────────────────────────
|
||||
const [entry] = await db
|
||||
.insert(performanceEntries)
|
||||
.values({
|
||||
versionId: version.id,
|
||||
hardwareSlug,
|
||||
userId: guard.user.id,
|
||||
fpsAvg,
|
||||
fpsLow,
|
||||
fpsOnePercentLow,
|
||||
fpsHigh,
|
||||
protonVersion: body.protonVersion ?? null,
|
||||
osVersion: body.osVersion ?? null,
|
||||
upscalerType,
|
||||
upscalerVersion: body.upscalerVersion ?? null,
|
||||
frameGenMethod,
|
||||
loadTimeSsd,
|
||||
loadTimeSd,
|
||||
tdpWatts,
|
||||
launchOptions: body.launchOptions ?? null,
|
||||
settingsJson: body.settingsJson ?? null,
|
||||
userNotes,
|
||||
customSystem: body.customSystem ?? false,
|
||||
})
|
||||
.returning()
|
||||
|
||||
// ── Update / create gamePlatformSupport ──────────────────────
|
||||
const [existingSupport] = await db
|
||||
.select()
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.gameId, game.id),
|
||||
eq(gamePlatformSupport.hardwareSlug, hardwareSlug),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingSupport) {
|
||||
await db
|
||||
.update(gamePlatformSupport)
|
||||
.set({
|
||||
antiCheatRelevant:
|
||||
body.antiCheatRelevant ?? existingSupport.antiCheatRelevant,
|
||||
antiCheatName: body.antiCheatRelevant
|
||||
? (body.antiCheatName ?? existingSupport.antiCheatName)
|
||||
: null,
|
||||
antiCheatStatus: (body.antiCheatStatus ??
|
||||
existingSupport.antiCheatStatus) as AntiCheatStatus,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gamePlatformSupport.id, existingSupport.id))
|
||||
} else {
|
||||
await db.insert(gamePlatformSupport).values({
|
||||
gameId: game.id,
|
||||
hardwareSlug,
|
||||
isSupported: true,
|
||||
protonStatus: "unknown",
|
||||
antiCheatRelevant: body.antiCheatRelevant ?? false,
|
||||
antiCheatName: body.antiCheatRelevant ? (body.antiCheatName ?? null) : null,
|
||||
antiCheatStatus: (body.antiCheatStatus ?? "unknown") as AntiCheatStatus,
|
||||
playabilityStatus: "unknown",
|
||||
})
|
||||
}
|
||||
|
||||
// Fire-and-forget playability recalculation
|
||||
recalculatePlayability(game.id).catch((err) =>
|
||||
console.error("Failed to recalculate playability:", err),
|
||||
)
|
||||
|
||||
set.status = 201
|
||||
return {
|
||||
id: entry.id,
|
||||
gameId: game.id,
|
||||
versionId: version.id,
|
||||
createdAt: entry.createdAt.toISOString(),
|
||||
authMethod: guard.keyId ? "api-key" : "session",
|
||||
}
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
version: t.Number(),
|
||||
steamAppId: t.Number(),
|
||||
hardwareSlug: t.String(),
|
||||
fpsAvg: t.Number(),
|
||||
fpsLow: t.Optional(t.Nullable(t.Number())),
|
||||
fpsOnePercentLow: t.Optional(t.Nullable(t.Number())),
|
||||
fpsHigh: t.Optional(t.Nullable(t.Number())),
|
||||
protonVersion: t.Optional(t.Nullable(t.String())),
|
||||
osVersion: t.Optional(t.Nullable(t.String())),
|
||||
upscalerType: t.Optional(t.String()),
|
||||
upscalerVersion: t.Optional(t.Nullable(t.String())),
|
||||
frameGenMethod: t.Optional(t.String()),
|
||||
tdpWatts: t.Optional(t.Nullable(t.Number())),
|
||||
loadTimeSsd: t.Optional(t.Nullable(t.Number())),
|
||||
loadTimeSd: t.Optional(t.Nullable(t.Number())),
|
||||
launchOptions: t.Optional(t.Nullable(t.String())),
|
||||
settingsJson: t.Optional(t.Nullable(t.Any())),
|
||||
userNotes: t.Optional(t.Nullable(t.String())),
|
||||
customSystem: t.Optional(t.Boolean()),
|
||||
antiCheatRelevant: t.Optional(t.Boolean()),
|
||||
antiCheatName: t.Optional(t.Nullable(t.String())),
|
||||
antiCheatStatus: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
description:
|
||||
"Import a performance benchmark from a DeckyVault plugin export (.deckyvault.json). " +
|
||||
"Accepts either session cookies or an x-api-key header for authentication. " +
|
||||
"The steamAppId is used to resolve the game and its latest version automatically.",
|
||||
},
|
||||
},
|
||||
)
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
import { adminClient, emailOTPClient, lastLoginMethodClient } from 'better-auth/client/plugins'
|
||||
import { passkeyClient } from '@better-auth/passkey/client'
|
||||
import { apiKeyClient } from '@better-auth/api-key/client'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_SITE_URL || "https://localhost:3000",
|
||||
@@ -8,7 +9,8 @@ export const authClient = createAuthClient({
|
||||
emailOTPClient(),
|
||||
passkeyClient(),
|
||||
lastLoginMethodClient(),
|
||||
adminClient()
|
||||
adminClient(),
|
||||
apiKeyClient(),
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
+14
@@ -2,6 +2,7 @@ import { betterAuth } from 'better-auth'
|
||||
import { admin, captcha, emailOTP, lastLoginMethod } from 'better-auth/plugins'
|
||||
import { passkey } from '@better-auth/passkey'
|
||||
import { expo } from '@better-auth/expo'
|
||||
import { apiKey } from '@better-auth/api-key'
|
||||
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
||||
import { db } from '@/lib/db/index'
|
||||
import { ac, admin as adminRole, moderator, contributor, user } from '@/lib/auth/permissions'
|
||||
@@ -72,6 +73,19 @@ export const auth = betterAuth({
|
||||
defaultRole: 'user',
|
||||
adminRoles: ['admin'],
|
||||
}),
|
||||
apiKey({
|
||||
defaultPrefix: 'dv_',
|
||||
requireName: true,
|
||||
keyExpiration: {
|
||||
defaultExpiresIn: null,
|
||||
disableCustomExpiresTime: false,
|
||||
},
|
||||
rateLimit: {
|
||||
enabled: true,
|
||||
timeWindow: 1000 * 60 * 60, // 1 hour
|
||||
maxRequests: 1000,
|
||||
},
|
||||
}),
|
||||
expo(),
|
||||
],
|
||||
socialProviders: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { user } from "@/lib/db/schema/auth"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { Session } from "better-auth"
|
||||
|
||||
type User = typeof auth.$Infer.Session.user
|
||||
|
||||
type ApiKeyGuardResult =
|
||||
| { ok: true; user: User; session: Session | null; keyId: string }
|
||||
| { ok: false; error: string; status: number }
|
||||
|
||||
/**
|
||||
* Attempts to authenticate a request using an API key from the x-api-key header.
|
||||
* Verifies the key via Better Auth and looks up the user from the database.
|
||||
*/
|
||||
export async function authenticateWithApiKey(
|
||||
requestHeaders: Headers,
|
||||
): Promise<ApiKeyGuardResult> {
|
||||
const apiKey = requestHeaders.get("x-api-key")
|
||||
if (!apiKey) {
|
||||
return { ok: false, error: "Missing x-api-key header", status: 401 }
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await auth.api.verifyApiKey({
|
||||
body: {
|
||||
key: apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (!result.valid || !result.key) {
|
||||
const errorMessage = String(result.error?.message ?? "Invalid API key")
|
||||
return { ok: false, error: errorMessage, status: 401 }
|
||||
}
|
||||
|
||||
const userId = result.key.referenceId
|
||||
const keyId = result.key.id
|
||||
|
||||
// Look up the user directly from the database
|
||||
const [dbUser] = await db
|
||||
.select()
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!dbUser) {
|
||||
return { ok: false, error: "User not found for API key", status: 401 }
|
||||
}
|
||||
|
||||
// Build a minimal user object matching Better Auth's Session.user type
|
||||
const authedUser: User = {
|
||||
id: dbUser.id,
|
||||
name: dbUser.name,
|
||||
email: dbUser.email,
|
||||
emailVerified: dbUser.emailVerified,
|
||||
image: dbUser.image,
|
||||
createdAt: dbUser.createdAt,
|
||||
updatedAt: dbUser.updatedAt,
|
||||
role: dbUser.role ?? "user",
|
||||
banned: dbUser.banned ?? null,
|
||||
banReason: dbUser.banReason ?? null,
|
||||
banExpires: dbUser.banExpires ?? null,
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: authedUser,
|
||||
session: null,
|
||||
keyId,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[api-key-guard] API key verification failed:", err)
|
||||
return { ok: false, error: "API key verification failed", status: 500 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined auth guard: first tries session auth (cookie), then falls back
|
||||
* to API key auth (x-api-key header). Returns the authenticated user.
|
||||
*/
|
||||
export async function requireAuthWithApiKeyFallback(
|
||||
requestHeaders: Headers,
|
||||
): Promise<ApiKeyGuardResult> {
|
||||
// Try session auth first
|
||||
const session = await auth.api.getSession({ headers: requestHeaders })
|
||||
if (session) {
|
||||
return {
|
||||
ok: true,
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
keyId: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to API key
|
||||
return authenticateWithApiKey(requestHeaders)
|
||||
}
|
||||
@@ -135,3 +135,46 @@ export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
export const apikey = pgTable(
|
||||
"apikey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
configId: text("config_id").notNull().default("default"),
|
||||
name: text("name"),
|
||||
start: text("start"),
|
||||
referenceId: text("reference_id").notNull(),
|
||||
prefix: text("prefix"),
|
||||
key: text("key").notNull(),
|
||||
refillInterval: integer("refill_interval"),
|
||||
refillAmount: integer("refill_amount"),
|
||||
lastRefillAt: timestamp("last_refill_at"),
|
||||
enabled: boolean("enabled").default(true).notNull(),
|
||||
rateLimitEnabled: boolean("rate_limit_enabled").default(true).notNull(),
|
||||
rateLimitTimeWindow: integer("rate_limit_time_window"),
|
||||
rateLimitMax: integer("rate_limit_max"),
|
||||
requestCount: integer("request_count").default(0).notNull(),
|
||||
remaining: integer("remaining"),
|
||||
lastRequest: timestamp("last_request"),
|
||||
expiresAt: timestamp("expires_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
permissions: text("permissions"),
|
||||
metadata: text("metadata"),
|
||||
},
|
||||
(table) => [
|
||||
index("apikey_config_id_idx").on(table.configId),
|
||||
index("apikey_reference_id_idx").on(table.referenceId),
|
||||
index("apikey_key_idx").on(table.key),
|
||||
],
|
||||
)
|
||||
|
||||
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [apikey.referenceId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user